(feat): add cache package
All checks were successful
/ build-web (push) Successful in 7m26s
/ build-desktop (linux) (push) Successful in 11m29s
/ build-mobile (push) Successful in 19m15s
/ release (push) Successful in 3m3s

(feat): improve local storage security
(feat): move settings to dedicated settings package
This commit is contained in:
Alois 2026-07-11 22:08:01 +02:00
commit 790a1db788
54 changed files with 1984 additions and 947 deletions

View file

@ -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": "*",

View file

@ -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} />;
}

View file

@ -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);
});
}

View 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,
};

View file

@ -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),
];
});
};