(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"exports": {
|
||||
"./session": "./src/session.tsx",
|
||||
"./context": "./src/context.tsx",
|
||||
"./indexed-db": "./src/indexed-db.ts"
|
||||
"./secure": "./src/secure.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
|
|
|
|||
|
|
@ -3,17 +3,32 @@ import {
|
|||
type Storage as StorageSchema,
|
||||
storageDefaults as defaults,
|
||||
} from "@tensamin/shared/data";
|
||||
import { getEntry, setEntry, deleteEntry } from "./indexed-db";
|
||||
import {
|
||||
deleteDatabaseEntry,
|
||||
getDatabaseEntry,
|
||||
setDatabaseEntry,
|
||||
} from "@tensamin/shared/indexedDb";
|
||||
import { ErrorScreen } from "@tensamin/ui";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import {
|
||||
decodeSecureValue,
|
||||
encodeSecureValue,
|
||||
getSecureStorageStatus,
|
||||
isSecureEnvelope,
|
||||
type SecureStorageStatus,
|
||||
} from "./secure";
|
||||
|
||||
export type SaveOptions = { secure?: boolean };
|
||||
|
||||
interface StorageContextValue {
|
||||
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
options?: SaveOptions,
|
||||
): Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
secureStorage: SecureStorageStatus | null;
|
||||
}
|
||||
|
||||
const StorageContext = React.createContext<StorageContextValue | undefined>(
|
||||
|
|
@ -30,96 +45,151 @@ const isIndexedDBSupported = typeof indexedDB !== "undefined";
|
|||
export default function StorageProvider(props: { children: React.ReactNode }) {
|
||||
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
|
||||
const storageRef = React.useRef(storage);
|
||||
const loadedKeys = React.useRef(new Set<keyof StorageSchema>());
|
||||
const loadPromises = React.useRef(
|
||||
new Map<keyof StorageSchema, Promise<StorageSchema[keyof StorageSchema]>>(),
|
||||
);
|
||||
const generations = React.useRef(new Map<keyof StorageSchema, number>());
|
||||
const [secureStorage, setSecureStorage] =
|
||||
React.useState<SecureStorageStatus | null>(null);
|
||||
const secureStorageRef = React.useRef<SecureStorageStatus | null>(null);
|
||||
|
||||
const [error, setError] = React.useState("");
|
||||
const [errorDescription, setErrorDescription] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
storageRef.current = storage;
|
||||
}, [storage]);
|
||||
void getSecureStorageStatus().then((status) => {
|
||||
secureStorageRef.current = status;
|
||||
setSecureStorage(status);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadIO = React.useCallback(
|
||||
const commit = React.useCallback(
|
||||
<K extends keyof StorageSchema>(key: K, nextValue: StorageSchema[K]) => {
|
||||
const next = { ...storageRef.current, [key]: nextValue };
|
||||
storageRef.current = next;
|
||||
loadedKeys.current.add(key);
|
||||
setStorage(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> => {
|
||||
let stored: StorageSchema[K] | undefined;
|
||||
if (loadedKeys.current.has(key)) return storageRef.current[key];
|
||||
const pending = loadPromises.current.get(key);
|
||||
if (pending) return pending as Promise<StorageSchema[K]>;
|
||||
|
||||
try {
|
||||
stored = await getEntry(key);
|
||||
} catch (err) {
|
||||
setError("Failed to load data");
|
||||
setErrorDescription(
|
||||
"An error occurred while loading data from IndexedDB. Please try again.",
|
||||
);
|
||||
log(0, "Storage", "red", err);
|
||||
}
|
||||
|
||||
if (stored !== undefined) {
|
||||
setStorage((prev) => ({ ...prev, [key]: stored }));
|
||||
return stored;
|
||||
}
|
||||
|
||||
return defaults[key];
|
||||
const generation = generations.current.get(key) ?? 0;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||
const desktopStatus = desktopStorage?.getStatus
|
||||
? await desktopStorage.getStatus()
|
||||
: null;
|
||||
const desktopValue =
|
||||
desktopStatus?.available && desktopStorage?.load
|
||||
? await desktopStorage.load(String(key))
|
||||
: null;
|
||||
let stored =
|
||||
desktopValue === null
|
||||
? await getDatabaseEntry<StorageSchema[K]>("storage", key)
|
||||
: (JSON.parse(desktopValue) as StorageSchema[K]);
|
||||
if (isSecureEnvelope(stored)) {
|
||||
stored = (await decodeSecureValue(stored)) as StorageSchema[K];
|
||||
}
|
||||
const value = stored ?? defaults[key];
|
||||
if ((generations.current.get(key) ?? 0) === generation) {
|
||||
commit(key, value);
|
||||
}
|
||||
return (generations.current.get(key) ?? 0) === generation
|
||||
? value
|
||||
: storageRef.current[key];
|
||||
} catch (err) {
|
||||
setError("Failed to load data");
|
||||
setErrorDescription(
|
||||
"An error occurred while loading local data. Please reload and try again.",
|
||||
);
|
||||
log(0, "Storage", "red", err);
|
||||
throw err;
|
||||
} finally {
|
||||
loadPromises.current.delete(key);
|
||||
}
|
||||
})();
|
||||
loadPromises.current.set(
|
||||
key,
|
||||
request as Promise<StorageSchema[keyof StorageSchema]>,
|
||||
);
|
||||
return request;
|
||||
},
|
||||
[],
|
||||
[commit],
|
||||
);
|
||||
|
||||
const saveIO = React.useCallback(
|
||||
const save = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
options: SaveOptions = {},
|
||||
): Promise<void> => {
|
||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||
await deleteEntry(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: defaults[key] }));
|
||||
if (desktopStorage?.delete)
|
||||
await desktopStorage.delete(String(key)).catch(() => undefined);
|
||||
await deleteDatabaseEntry("storage", key);
|
||||
commit(key, defaults[key]);
|
||||
} else {
|
||||
await setEntry(key, value);
|
||||
setStorage((prev) => ({ ...prev, [key]: value }));
|
||||
const status = options.secure
|
||||
? (secureStorageRef.current ?? (await getSecureStorageStatus()))
|
||||
: secureStorageRef.current;
|
||||
if (
|
||||
options.secure &&
|
||||
status?.backend === "electron-keyring" &&
|
||||
desktopStorage?.save
|
||||
) {
|
||||
await desktopStorage.save(String(key), JSON.stringify(value));
|
||||
await deleteDatabaseEntry("storage", key);
|
||||
} else {
|
||||
const persisted = options.secure
|
||||
? await encodeSecureValue(value)
|
||||
: value;
|
||||
await setDatabaseEntry("storage", key, persisted as StorageSchema[K]);
|
||||
}
|
||||
commit(key, value);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[commit],
|
||||
);
|
||||
|
||||
const clear = React.useCallback(async () => {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
for (const key of keys) {
|
||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||
}
|
||||
await Promise.all(
|
||||
keys.map((key) => deleteDatabaseEntry("storage", key)),
|
||||
);
|
||||
await window.tensaminDesktop?.secureStorage
|
||||
?.clear?.()
|
||||
.catch(() => undefined);
|
||||
loadedKeys.current.clear();
|
||||
loadPromises.current.clear();
|
||||
storageRef.current = defaults;
|
||||
setStorage(defaults);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<StorageContextValue>(
|
||||
() => ({
|
||||
async load<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> {
|
||||
const current = storageRef.current[key];
|
||||
|
||||
if (
|
||||
current === undefined ||
|
||||
JSON.stringify(current) === JSON.stringify(defaults[key])
|
||||
) {
|
||||
const loadedValue = await loadIO(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: loadedValue }));
|
||||
return loadedValue;
|
||||
}
|
||||
|
||||
return current;
|
||||
},
|
||||
|
||||
async save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
nextValue: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
await saveIO(key, nextValue);
|
||||
},
|
||||
|
||||
async clear() {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
await Promise.all(keys.map((key) => deleteEntry(key)));
|
||||
setStorage(defaults);
|
||||
},
|
||||
load,
|
||||
save,
|
||||
clear,
|
||||
secureStorage,
|
||||
}),
|
||||
[loadIO, saveIO],
|
||||
[clear, load, save, secureStorage],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// @ts-expect-error development utility
|
||||
window.save = value.save;
|
||||
}, [value]);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import type { Storage as StorageSchema } from "@tensamin/shared/data";
|
||||
|
||||
const DB_NAME = "tensamin";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "storage";
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
/**
|
||||
* Executes openDB.
|
||||
* @param none This function has no parameters.
|
||||
* @returns Promise<IDBDatabase>.
|
||||
*/
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes getEntry.
|
||||
* @param key Parameter key.
|
||||
* @returns Promise<StorageSchema[K] | undefined>.
|
||||
*/
|
||||
export async function getEntry<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K] | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(key as string);
|
||||
|
||||
request.onsuccess = () =>
|
||||
resolve(request.result as StorageSchema[K] | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes setEntry.
|
||||
* @param key Parameter key.
|
||||
* @param value Parameter value.
|
||||
* @returns Promise<void>.
|
||||
*/
|
||||
export async function setEntry<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(value, key as string);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes deleteEntry.
|
||||
* @param key Parameter key.
|
||||
* @returns Promise<void>.
|
||||
*/
|
||||
export async function deleteEntry<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(key as string);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
142
packages/storage/src/secure.ts
Normal file
142
packages/storage/src/secure.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type {} from "@tensamin/shared/desktopMedia";
|
||||
import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||
|
||||
export type SecureStorageStatus = {
|
||||
backend: "electron-keyring" | "webcrypto" | "indexeddb";
|
||||
secure: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SecureEnvelope = {
|
||||
__tensaminSecure: 1;
|
||||
version: 1;
|
||||
iv: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
const MASTER_KEY_NAME = "master-v1";
|
||||
let keyPromise: Promise<CryptoKey | null> | undefined;
|
||||
|
||||
function bytesToBase64(value: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function loadBrowserKey() {
|
||||
const existing = await getDatabaseEntry<CryptoKey>("keys", MASTER_KEY_NAME);
|
||||
if (existing) return existing;
|
||||
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
await setDatabaseEntry("keys", MASTER_KEY_NAME, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function loadElectronKey() {
|
||||
const storage = window.tensaminDesktop?.secureStorage;
|
||||
if (!storage?.getStatus || !storage.load || !storage.save) return null;
|
||||
const status = await storage.getStatus();
|
||||
if (!status.available) return null;
|
||||
|
||||
let encoded = await storage.load(MASTER_KEY_NAME);
|
||||
if (encoded === null) {
|
||||
encoded = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
||||
await storage.save(MASTER_KEY_NAME, encoded);
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
base64ToBytes(encoded),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
async function getKey() {
|
||||
keyPromise ??= (async () => {
|
||||
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
|
||||
return loadBrowserKey();
|
||||
})().catch(() => null);
|
||||
return keyPromise;
|
||||
}
|
||||
|
||||
export function isSecureEnvelope(value: unknown): value is SecureEnvelope {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const envelope = value as Partial<SecureEnvelope>;
|
||||
return (
|
||||
envelope.__tensaminSecure === 1 &&
|
||||
envelope.version === 1 &&
|
||||
typeof envelope.iv === "string" &&
|
||||
typeof envelope.data === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function encodeSecureValue(value: unknown): Promise<unknown> {
|
||||
const key = await getKey();
|
||||
if (!key) return value;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
plaintext,
|
||||
);
|
||||
return {
|
||||
__tensaminSecure: 1,
|
||||
version: 1,
|
||||
iv: bytesToBase64(iv),
|
||||
data: bytesToBase64(new Uint8Array(encrypted)),
|
||||
} satisfies SecureEnvelope;
|
||||
}
|
||||
|
||||
export async function decodeSecureValue(value: unknown): Promise<unknown> {
|
||||
if (!isSecureEnvelope(value)) return value;
|
||||
const key = await getKey();
|
||||
if (!key) throw new Error("Secure storage key is unavailable.");
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToBytes(value.iv) },
|
||||
key,
|
||||
base64ToBytes(value.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
|
||||
}
|
||||
|
||||
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
|
||||
const desktop = window.tensaminDesktop?.secureStorage;
|
||||
if (desktop?.getStatus) {
|
||||
const status = await desktop.getStatus();
|
||||
if (status.available) return { backend: "electron-keyring", secure: true };
|
||||
return {
|
||||
backend: "indexeddb",
|
||||
secure: false,
|
||||
reason:
|
||||
status.backend === "basic_text"
|
||||
? "The operating system keyring is unavailable."
|
||||
: "Electron secure storage is unavailable.",
|
||||
};
|
||||
}
|
||||
return (await getKey())
|
||||
? { backend: "webcrypto", secure: true }
|
||||
: {
|
||||
backend: "indexeddb",
|
||||
secure: false,
|
||||
reason: "This browser cannot protect local credentials with WebCrypto.",
|
||||
};
|
||||
}
|
||||
|
||||
export const secureValueCodec = {
|
||||
encode: encodeSecureValue,
|
||||
decode: decodeSecureValue,
|
||||
};
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
} from "react";
|
||||
import { useStorage } from "./context";
|
||||
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
|
||||
import { createCache } from "@tensamin/cache";
|
||||
import { secureValueCodec } from "./secure";
|
||||
|
||||
interface SessionContextType {
|
||||
contacts: Contacts;
|
||||
|
|
@ -21,11 +23,13 @@ interface SessionContextType {
|
|||
const SessionContext = createContext<SessionContextType | undefined>(undefined);
|
||||
|
||||
export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const { freshContacts, freshCommunities, freshCalls } = useMTP();
|
||||
const { freshContacts, freshCommunities, freshCalls, contextReady } =
|
||||
useMTP();
|
||||
const { load, save } = useStorage();
|
||||
const [contacts, setContacts] = useState<Contacts>([]);
|
||||
const [communities, setCommunities] = useState<Communities>([]);
|
||||
const [localCalls, setLocalCalls] = useState<Calls>([]);
|
||||
const [accountId, setAccountId] = useState<number | null>(null);
|
||||
const calls = [
|
||||
...freshCalls,
|
||||
...localCalls.filter(
|
||||
|
|
@ -33,17 +37,27 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
),
|
||||
];
|
||||
|
||||
// Cached session data fills in items the server did not return freshly.
|
||||
useEffect(() => {
|
||||
load("cached_contacts").then((cachedData) => {
|
||||
setContacts([
|
||||
...freshContacts,
|
||||
...cachedData.filter(
|
||||
(item) =>
|
||||
!freshContacts.some((fresh) => fresh.UserId === item.UserId),
|
||||
),
|
||||
]);
|
||||
void load("user_id").then(setAccountId);
|
||||
}, [load]);
|
||||
|
||||
// Cached contacts seed the session, then authenticated server data replaces them.
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
const cache = createCache(String(accountId), {
|
||||
codec: secureValueCodec,
|
||||
});
|
||||
void cache.contacts.get().then((cached) => {
|
||||
if (cached) setContacts(cached);
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !contextReady) return;
|
||||
setContacts(freshContacts);
|
||||
}, [accountId, contextReady, freshContacts]);
|
||||
|
||||
useEffect(() => {
|
||||
load("cached_communities").then((cachedData) => {
|
||||
if (cachedData && freshCommunities) {
|
||||
setCommunities([
|
||||
|
|
@ -57,11 +71,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
]);
|
||||
}
|
||||
});
|
||||
}, [load, freshContacts, freshCommunities]);
|
||||
|
||||
useEffect(() => {
|
||||
save("cached_contacts", contacts);
|
||||
}, [contacts, save]);
|
||||
}, [load, freshCommunities]);
|
||||
useEffect(() => {
|
||||
save("cached_communities", communities);
|
||||
}, [communities, save]);
|
||||
|
|
@ -72,8 +82,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
(contact) => contact.UserId === userId,
|
||||
);
|
||||
if (userIndex === -1) return prevContacts;
|
||||
const [user] = prevContacts.splice(userIndex, 1);
|
||||
return [user, ...prevContacts];
|
||||
const user = prevContacts[userIndex];
|
||||
return [
|
||||
user,
|
||||
...prevContacts.slice(0, userIndex),
|
||||
...prevContacts.slice(userIndex + 1),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue