From 49c64cc5d9dc95169d4fd5213da1d5501c7b9b78 Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Sun, 19 Apr 2026 21:21:40 +0200 Subject: [PATCH] feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold --- apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 25 ++ apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 15 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/lib/secretStore.ts | 42 ++-- apps/desktop/src/lib/secureFileStore.ts | 226 ++++++++++++++++++ pnpm-lock.yaml | 10 + 9 files changed, 298 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/lib/secureFileStore.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index df5475c..185ff8c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,6 +23,7 @@ "@livekit/components-react": "^2.9.0", "@supabase/supabase-js": "^2.46.0", "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-notification": "^2.0.1", "@tauri-apps/plugin-sql": "^2.0.1", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 984bdb9..786e91d 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -684,6 +684,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-sql", @@ -5483,6 +5484,30 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-fs" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e0543fb..1506636 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri = { version = "2", features = ["devtools"] } tauri-plugin-notification = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-stronghold = "2" +tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 598cab3..cc53f5d 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -23,6 +23,19 @@ "stronghold:allow-save", "stronghold:allow-get-store-record", "stronghold:allow-save-store-record", - "stronghold:allow-remove-store-record" + "stronghold:allow-remove-store-record", + "fs:default", + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-mkdir", + "fs:allow-exists", + "fs:allow-rename", + "fs:allow-remove", + { + "identifier": "fs:scope", + "allow": [ + { "path": "$APPLOCALDATA/**" } + ] + } ] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cb5804b..53aea41 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_sql::Builder::default().build()) + .plugin(tauri_plugin_fs::init()) .plugin( tauri_plugin_stronghold::Builder::new(|password| { // TODO: derive stronghold key from password using argon2 / blake2b. diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 5acd4e0..88121c1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ChatApp", - "version": "0.3.8", + "version": "0.4.0", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/lib/secretStore.ts b/apps/desktop/src/lib/secretStore.ts index 2dae84c..891b556 100644 --- a/apps/desktop/src/lib/secretStore.ts +++ b/apps/desktop/src/lib/secretStore.ts @@ -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 { 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 { } } -// 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; } diff --git a/apps/desktop/src/lib/secureFileStore.ts b/apps/desktop/src/lib/secureFileStore.ts new file mode 100644 index 0000000..49fd6d8 --- /dev/null +++ b/apps/desktop/src/lib/secureFileStore.ts @@ -0,0 +1,226 @@ +import type { SecretStore } from '@chat-app/shared/auth'; +import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs'; +import { appLocalDataDir } from '@tauri-apps/api/path'; +import sodium from 'libsodium-wrappers'; + +// Encrypted single-file SecretStore for Tauri. Replaces the flaky +// tauri-plugin-stronghold implementation. +// +// File layout (binary, little-endian): +// bytes 0..7 magic: ASCII "CHATVLT1" +// bytes 8..23 salt for KDF (16 bytes) +// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes) +// bytes 48.. secretbox(plaintext_json, key, nonce) +// +// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }. +// +// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase +// derived from the authenticated user-id + a constant. Same userId on the +// same machine after re-install ⇒ same key ⇒ vault recovers automatically. +// +// Atomic writes: serialised vault is first written to `.tmp` then +// renamed onto `` so an interrupted write never corrupts the existing +// vault. + +const FILE_NAME = 'chatapp-vault.bin'; +const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes +const SALT_LEN = 16; +const NONCE_LEN = 24; +const KEY_LEN = 32; + +interface VaultState { + path: string; + tmpPath: string; + key: Uint8Array; // derived encryption key + data: Map; +} + +let initPromise: Promise | null = null; +let vault: VaultState | null = null; +let initializedFor: string | null = null; + +async function ensureSodium(): Promise { + await sodium.ready; + return sodium; +} + +function joinPath(dir: string, name: string): string { + const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/'; + return dir + sep + name; +} + +function b64url(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function unb64url(s: string): Uint8Array { + let str = s.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + const bin = atob(str); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise { + const passphrase = 'chatapp-vault-v1:' + userId; + return s.crypto_pwhash( + KEY_LEN, + passphrase, + salt, + s.crypto_pwhash_OPSLIMIT_MODERATE, + s.crypto_pwhash_MEMLIMIT_MODERATE, + s.crypto_pwhash_ALG_ARGON2ID13, + ); +} + +async function loadOrCreateVault(userId: string): Promise { + const s = await ensureSodium(); + const dir = await appLocalDataDir(); + const path = joinPath(dir, FILE_NAME); + const tmpPath = path + '.tmp'; + + try { + await mkdir(dir, { recursive: true }); + } catch { + /* parent likely already exists */ + } + + const fileExists = await exists(path).catch(() => false); + if (!fileExists) { + const salt = s.randombytes_buf(SALT_LEN); + const key = await deriveKey(userId, salt, s); + const state: VaultState = { path, tmpPath, key, data: new Map() }; + await persist(state, salt, s); + return state; + } + + const raw = await readFile(path); + if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) { + throw new Error('vault file too short'); + } + for (let i = 0; i < MAGIC.length; i++) { + if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch'); + } + const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN); + const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN); + const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN); + + const key = await deriveKey(userId, salt, s); + let plain: Uint8Array; + try { + plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key); + } catch (err: unknown) { + throw new Error( + 'vault decrypt failed (wrong user / corrupted file): ' + + (err instanceof Error ? err.message : String(err)), + ); + } + const json = new TextDecoder().decode(plain) || '{}'; + const obj = JSON.parse(json) as Record; + const data = new Map(); + for (const [k, v] of Object.entries(obj)) { + try { + data.set(k, unb64url(v)); + } catch { + /* skip malformed entries */ + } + } + return { path, tmpPath, key, data }; +} + +async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise { + const obj: Record = {}; + for (const [k, v] of state.data) obj[k] = b64url(v); + const plain = new TextEncoder().encode(JSON.stringify(obj)); + const nonce = s.randombytes_buf(NONCE_LEN); + const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key); + + const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length); + out.set(MAGIC, 0); + out.set(salt, MAGIC.length); + out.set(nonce, MAGIC.length + SALT_LEN); + out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN); + + // Atomic write: tmp → rename. `rename` on the same filesystem is atomic + // on macOS, Linux, and Windows (NTFS). + await writeFile(state.tmpPath, out); + await rename(state.tmpPath, state.path); +} + +// Re-derives the salt by reading the existing file header so persist() can +// keep using the same KDF salt across writes (we don't rotate KDF on every +// save — only on initial vault creation). +async function readSalt(state: VaultState): Promise { + const raw = await readFile(state.path); + return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN); +} + +async function ensureInit(userId: string): Promise { + if (initializedFor === userId && vault) return vault; + if (initPromise) return initPromise; + initPromise = loadOrCreateVault(userId) + .then((v) => { + vault = v; + initializedFor = userId; + return v; + }) + .finally(() => { + initPromise = null; + }); + return initPromise; +} + +export function makeSecureFileStore(userId: string): SecretStore { + return { + async getSecret(key: string): Promise { + const v = await ensureInit(userId); + const found = v.data.get(key); + return found ? new Uint8Array(found) : null; + }, + async setSecret(key: string, value: Uint8Array): Promise { + const v = await ensureInit(userId); + v.data.set(key, new Uint8Array(value)); + const s = await ensureSodium(); + const salt = await readSalt(v); + await persist(v, salt, s); + }, + async removeSecret(key: string): Promise { + const v = await ensureInit(userId); + v.data.delete(key); + const s = await ensureSodium(); + const salt = await readSalt(v); + await persist(v, salt, s); + }, + }; +} + +// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted +// vault on first init. Idempotent — checks for marker key. +export async function migrateLocalStorageToVault( + userId: string, + prefix: string, +): Promise { + const v = await ensureInit(userId); + if (v.data.has('__migrated_from_localstorage')) 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); + v.data.set(shortKey, decoded); + } catch { + /* skip malformed */ + } + } + v.data.set('__migrated_from_localstorage', new Uint8Array([1])); + const s = await ensureSodium(); + const salt = await readSalt(v); + await persist(v, salt, s); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0969470..7b40a0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@tauri-apps/api': specifier: ^2.1.1 version: 2.10.1 + '@tauri-apps/plugin-fs': + specifier: ^2.5.0 + version: 2.5.0 '@tauri-apps/plugin-global-shortcut': specifier: ^2.3.1 version: 2.3.1 @@ -1766,6 +1769,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-fs@2.5.0': + resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} + '@tauri-apps/plugin-global-shortcut@2.3.1': resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==} @@ -7401,6 +7407,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-fs@2.5.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-global-shortcut@2.3.1': dependencies: '@tauri-apps/api': 2.10.1