From 0d94b684bf2be173dd5d002c2dea7b8ff49b0683 Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Sun, 19 Apr 2026 18:41:35 +0200 Subject: [PATCH] feat(crypto): stronghold persistence + passphrase device-key backup/restore --- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/context/AuthContext.tsx | 2 + apps/desktop/src/lib/deviceBackup.ts | 93 +++++++++++++ apps/desktop/src/lib/secretStore.ts | 56 +++++++- apps/desktop/src/lib/strongholdStore.ts | 106 +++++++++++++++ apps/desktop/src/pages/SettingsPage.tsx | 158 +++++++++++++++++++++++ packages/shared/src/auth/device.ts | 11 ++ 7 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/lib/deviceBackup.ts create mode 100644 apps/desktop/src/lib/strongholdStore.ts diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 232ea75..51acffd 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.1.2", + "version": "0.2.0", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index 3055529..077e9ab 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -18,6 +18,7 @@ import { import { useTranslation } from 'react-i18next'; import { findExistingDevice } from '../lib/device'; +import { setSecretStoreUser } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; interface AuthContextValue { @@ -81,6 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => { setSession(s); setReady(true); + void setSecretStoreUser(s?.user.id ?? null); }); return () => { cancelled = true; diff --git a/apps/desktop/src/lib/deviceBackup.ts b/apps/desktop/src/lib/deviceBackup.ts new file mode 100644 index 0000000..68adfca --- /dev/null +++ b/apps/desktop/src/lib/deviceBackup.ts @@ -0,0 +1,93 @@ +import { getCryptoBackend } from '@chat-app/shared/crypto'; +import sodium from 'libsodium-wrappers'; + +// Encrypts/decrypts the device private key with a user-provided passphrase +// so the backup string can be safely written down or stored in a password +// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and +// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD. +// +// Backup format (base64url-encoded blob, prefixed with a magic string so we +// can version it): +// +// chatapp-backup-v1. + +const MAGIC = 'chatapp-backup-v1.'; +const SALT_LEN = 16; // crypto_pwhash_SALTBYTES +const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES +const KEY_LEN = 32; // crypto_secretbox_KEYBYTES + +async function ensureSodium(): Promise { + await sodium.ready; + return sodium; +} + +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(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise { + return sodiumLib.crypto_pwhash( + KEY_LEN, + passphrase, + salt, + sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE, + sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE, + sodiumLib.crypto_pwhash_ALG_ARGON2ID13, + ); +} + +export async function exportDeviceKey( + privateKey: Uint8Array, + passphrase: string, +): Promise { + if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.'); + const s = await ensureSodium(); + const salt = s.randombytes_buf(SALT_LEN); + const nonce = s.randombytes_buf(NONCE_LEN); + const key = await deriveKey(passphrase, salt, s); + const backend = getCryptoBackend(); + const ciphertext = backend.secretbox(privateKey, nonce, key); + s.memzero(key); + const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length); + blob.set(salt, 0); + blob.set(nonce, SALT_LEN); + blob.set(ciphertext, SALT_LEN + NONCE_LEN); + return MAGIC + b64url(blob); +} + +export async function importDeviceKey( + backup: string, + passphrase: string, +): Promise { + if (!backup.startsWith(MAGIC)) { + throw new Error('Invalid backup format'); + } + const blob = unb64url(backup.slice(MAGIC.length)); + if (blob.length < SALT_LEN + NONCE_LEN + 1) { + throw new Error('Backup too short'); + } + const salt = blob.slice(0, SALT_LEN); + const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN); + const ciphertext = blob.slice(SALT_LEN + NONCE_LEN); + const s = await ensureSodium(); + const key = await deriveKey(passphrase, salt, s); + const backend = getCryptoBackend(); + try { + return backend.secretboxOpen(ciphertext, nonce, key); + } catch { + throw new Error('Wrong passphrase or corrupt backup'); + } finally { + s.memzero(key); + } +} diff --git a/apps/desktop/src/lib/secretStore.ts b/apps/desktop/src/lib/secretStore.ts index 3d6ca45..c3cd8d8 100644 --- a/apps/desktop/src/lib/secretStore.ts +++ b/apps/desktop/src/lib/secretStore.ts @@ -1,12 +1,21 @@ import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth'; -// M1 dev-only impl: persists secrets as base64 in localStorage. -// Swap this out for a tauri-plugin-stronghold implementation before release. -// The SecretStore interface stays identical so callers won't notice. +import { isTauriRuntime } from './globalShortcut'; +import { makeStrongholdStore, migrateLocalStorageToStronghold } from './strongholdStore'; + +// 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). +// +// 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. const PREFIX = 'chatapp.secret:'; -export const devLocalSecretStore: SecretStore = { +const localStore: SecretStore = { async getSecret(key: string): Promise { const raw = window.localStorage.getItem(PREFIX + key); if (!raw) return null; @@ -20,3 +29,42 @@ export const devLocalSecretStore: SecretStore = { window.localStorage.removeItem(PREFIX + key); }, }; + +let activeBackend: SecretStore = localStore; +let activeUserId: string | null = null; + +export async function setSecretStoreUser(userId: string | null): Promise { + if (userId === activeUserId) return; + activeUserId = userId; + + if (userId && isTauriRuntime()) { + const stronghold = makeStrongholdStore(userId); + activeBackend = stronghold; + try { + await migrateLocalStorageToStronghold(userId, PREFIX); + } catch (err: unknown) { + console.warn('stronghold migration failed', err); + } + } else { + activeBackend = localStore; + } +} + +// 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); + }, + async setSecret(key, value) { + return activeBackend.setSecret(key, value); + }, + async removeSecret(key) { + return activeBackend.removeSecret(key); + }, +}; + +export function isStrongholdActive(): boolean { + return activeBackend !== localStore; +} diff --git a/apps/desktop/src/lib/strongholdStore.ts b/apps/desktop/src/lib/strongholdStore.ts new file mode 100644 index 0000000..3779145 --- /dev/null +++ b/apps/desktop/src/lib/strongholdStore.ts @@ -0,0 +1,106 @@ +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 | null = null; +let initializedFor: string | null = null; + +async function derivePassword(userId: string): Promise { + 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 { + 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 { + 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 { + await ensureInit(userId); + await storeRef!.insert(key, Array.from(value)); + await strongholdRef!.save(); + }, + async removeSecret(key: string): Promise { + 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 { + 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(); +} diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index be92658..a7755b8 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -9,6 +9,9 @@ import { useTranslation } from 'react-i18next'; import { LockIcon } from '../components/icons'; import { useAuth } from '../context/AuthContext'; +import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth'; +import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup'; +import { devLocalSecretStore } from '../lib/secretStore'; import { getPttSettings, keyCodeToLabel, @@ -161,6 +164,7 @@ export function SettingsPage() { )} + {/* Danger zone */} @@ -323,6 +327,160 @@ function AudioQualityControls() { ); } +function DeviceKeyBackupControls() { + const { t } = useTranslation(['app']); + const { profile, device } = useAuth(); + const [busy, setBusy] = useState(false); + const [backupOut, setBackupOut] = useState(null); + const [exportPass, setExportPass] = useState(''); + const [importPass, setImportPass] = useState(''); + const [importBlob, setImportBlob] = useState(''); + const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + const canRun = !!profile?.userId && !!device?.id; + + async function handleExport() { + if (!canRun) return; + setMsg(null); + setBusy(true); + try { + const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id); + if (!priv) throw new Error('No device key on this install'); + const out = await exportDeviceKey(priv, exportPass); + setBackupOut(out); + setExportPass(''); + setMsg({ + kind: 'ok', + text: t('app:settings.backup_export_ok', { + defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.', + }), + }); + } catch (err: unknown) { + setMsg({ + kind: 'err', + text: err instanceof Error ? err.message : 'export failed', + }); + } finally { + setBusy(false); + } + } + + async function handleImport() { + if (!canRun) return; + setMsg(null); + setBusy(true); + try { + const priv = await importDeviceKey(importBlob.trim(), importPass); + await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv); + setImportBlob(''); + setImportPass(''); + setMsg({ + kind: 'ok', + text: t('app:settings.backup_import_ok', { + defaultValue: + 'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.', + }), + }); + } catch (err: unknown) { + setMsg({ + kind: 'err', + text: err instanceof Error ? err.message : 'import failed', + }); + } finally { + setBusy(false); + } + } + + return ( +
+
+ {t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })} +
+

+ {t('app:settings.device_key_backup_hint', { + defaultValue: + 'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.', + })} +

+ +
+
+ {t('app:settings.backup_export', { defaultValue: 'Export' })} +
+
+ setExportPass(e.target.value)} + placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })} + className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none" + /> + +
+ {backupOut && ( +