feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+25
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `<file>.tmp` then
|
||||
// renamed onto `<file>` 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<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
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<Uint8Array> {
|
||||
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<VaultState> {
|
||||
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<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
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<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
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<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
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<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user