Files
ChatApp/apps/desktop/src/lib/strongholdStore.ts
T
byGalax 0d94b684bf
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
feat(crypto): stronghold persistence + passphrase device-key backup/restore
2026-04-19 18:41:35 +02:00

107 lines
3.7 KiB
TypeScript

import type { SecretStore } from '@chat-app/shared/auth';
import { appLocalDataDir } from '@tauri-apps/api/path';
import { type Client, type Store, Stronghold } from '@tauri-apps/plugin-stronghold';
// Stronghold-backed SecretStore. Vault file lives in Tauri's
// `appLocalDataDir/chatapp.stronghold` and survives app re-installs (the
// directory is preserved by the OS on macOS/Windows/Linux unless the user
// manually removes it). Vault password is derived from the Supabase user-id
// so the same user re-installing the app on the same machine recovers their
// device key automatically.
const VAULT_NAME = 'chatapp.stronghold';
const CLIENT_NAME = 'chatapp';
let strongholdRef: Stronghold | null = null;
let storeRef: Store | null = null;
let initPromise: Promise<void> | null = null;
let initializedFor: string | null = null;
async function derivePassword(userId: string): Promise<string> {
const enc = new TextEncoder();
const buf = await crypto.subtle.digest(
'SHA-256',
enc.encode('chatapp-stronghold-v1:' + userId),
);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function ensureInit(userId: string): Promise<void> {
if (initializedFor === userId && storeRef) return;
if (initPromise) return initPromise;
initPromise = (async () => {
const dir = await appLocalDataDir();
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
const vaultPath = dir + sep + VAULT_NAME;
const password = await derivePassword(userId);
strongholdRef = await Stronghold.load(vaultPath, password);
let client: Client;
try {
client = await strongholdRef.loadClient(CLIENT_NAME);
} catch {
client = await strongholdRef.createClient(CLIENT_NAME);
}
storeRef = client.getStore();
initializedFor = userId;
})().finally(() => {
initPromise = null;
});
return initPromise;
}
export function makeStrongholdStore(userId: string): SecretStore {
return {
async getSecret(key: string): Promise<Uint8Array | null> {
await ensureInit(userId);
const val = await storeRef!.get(key);
if (!val) return null;
return val instanceof Uint8Array ? val : new Uint8Array(val);
},
async setSecret(key: string, value: Uint8Array): Promise<void> {
await ensureInit(userId);
await storeRef!.insert(key, Array.from(value));
await strongholdRef!.save();
},
async removeSecret(key: string): Promise<void> {
await ensureInit(userId);
await storeRef!.remove(key);
await strongholdRef!.save();
},
};
}
// One-time migration: copies any keys we find in localStorage (the legacy
// dev store) into Stronghold so a user who upgrades from a localStorage-only
// build doesn't lose their device key. Safe to call multiple times — no-op
// once the marker key is present.
export async function migrateLocalStorageToStronghold(
userId: string,
prefix: string,
): Promise<void> {
await ensureInit(userId);
if (!storeRef) return;
const markerKey = '__migrated_from_localstorage';
const already = await storeRef.get(markerKey);
if (already) return;
for (let i = 0; i < window.localStorage.length; i++) {
const fullKey = window.localStorage.key(i);
if (!fullKey || !fullKey.startsWith(prefix)) continue;
const raw = window.localStorage.getItem(fullKey);
if (!raw) continue;
try {
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
const shortKey = fullKey.slice(prefix.length);
await storeRef.insert(shortKey, Array.from(decoded));
} catch {
// Skip malformed entries.
}
}
await storeRef.insert(markerKey, [1]);
if (strongholdRef) await strongholdRef.save();
}