feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeStrongholdStore, migrateLocalStorageToStronghold } from './strongholdStore';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
|
||||
// Secret store with two backends:
|
||||
// - Tauri: Stronghold-encrypted vault file in appLocalDataDir. Survives app
|
||||
// reinstalls and is encrypted at rest with a password derived from the
|
||||
// authenticated user-id.
|
||||
// - Web / pre-auth: localStorage (legacy dev fallback).
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
// Callers don't need to care which one is active — they import a singleton
|
||||
// and call setSecretStoreUser(userId) once the session is known. Until that
|
||||
// happens, calls fall through to localStorage.
|
||||
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
|
||||
// once the session is known. The singleton object's identity is stable so
|
||||
// existing imports keep working.
|
||||
|
||||
const PREFIX = 'chatapp.secret:';
|
||||
|
||||
@@ -38,21 +38,20 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const stronghold = makeStrongholdStore(userId);
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
try {
|
||||
// Force a tiny round-trip to verify Stronghold can actually open the
|
||||
// vault on this machine. If not (broken vault file, bundled rust crate
|
||||
// mismatch, etc.) we fall back to localStorage so the rest of the app
|
||||
// remains usable instead of bricking device registration.
|
||||
await stronghold.getSecret('__probe');
|
||||
activeBackend = stronghold;
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
try {
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('stronghold migration failed', err);
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('stronghold init failed — falling back to localStorage', err);
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
@@ -60,9 +59,6 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton with stable identity — internals delegate to whichever backend is
|
||||
// currently active. Existing call-sites that imported `devLocalSecretStore`
|
||||
// keep working without changes.
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
async getSecret(key) {
|
||||
return activeBackend.getSecret(key);
|
||||
@@ -75,6 +71,6 @@ export const devLocalSecretStore: SecretStore = {
|
||||
},
|
||||
};
|
||||
|
||||
export function isStrongholdActive(): boolean {
|
||||
export function isEncryptedVaultActive(): boolean {
|
||||
return activeBackend !== localStore;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user