feat(crypto): stronghold persistence + passphrase device-key backup/restore
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
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
This commit is contained in:
@@ -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.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
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<typeof sodium> {
|
||||
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<Uint8Array> {
|
||||
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<string> {
|
||||
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<Uint8Array> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Uint8Array | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
Reference in New Issue
Block a user