import { getCryptoBackend } from '@chat-app/shared/crypto'; import sodium from 'libsodium-wrappers-sumo'; import { pwhashArgon2id } from './nativeCryptoOps'; // 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 pwhashArgon2id({ password: passphrase, salt, outLen: KEY_LEN, preset: 'moderate', }); } 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); } } // Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload // before encrypting, so a restore flow can re-seed localStorage + vault + server // device row without requiring the user to remember IDs. export interface DeviceBackupPayload { v: 2; userId: string; deviceId: string; privateKeyB64: string; } export async function exportDeviceBackup( params: { userId: string; deviceId: string; privateKey: Uint8Array; passphrase: string; }, ): Promise { const payload: DeviceBackupPayload = { v: 2, userId: params.userId, deviceId: params.deviceId, privateKeyB64: b64url(params.privateKey), }; const bytes = new TextEncoder().encode(JSON.stringify(payload)); return exportDeviceKey(bytes, params.passphrase); } export async function importDeviceBackup( backup: string, passphrase: string, ): Promise { const plain = await importDeviceKey(backup, passphrase); const text = new TextDecoder().decode(plain); try { const obj = JSON.parse(text) as DeviceBackupPayload; if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) { return obj; } } catch { /* fall through */ } throw new Error('Backup format not supported — v2 expected'); } export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array { return unb64url(payload.privateKeyB64); } // --------------------------------------------------------------------------- // Recovery code // --------------------------------------------------------------------------- // // Generates a high-entropy code shown to the user once at backup time. The // same payload is encrypted twice — once with the user's passphrase, once // with the recovery code — so either string can decrypt the device key. // // The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous // characters), grouped as 4×6. ~120 bits of entropy. const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; export interface BackupBundle { passphraseBackup: string; recoveryBackup: string; recoveryCode: string; } export async function exportDeviceBackupWithRecovery(params: { userId: string; deviceId: string; privateKey: Uint8Array; passphrase: string; }): Promise { const recoveryCode = await generateRecoveryCode(); const [passphraseBackup, recoveryBackup] = await Promise.all([ exportDeviceBackup(params), exportDeviceBackup({ ...params, passphrase: recoveryCode }), ]); return { passphraseBackup, recoveryBackup, recoveryCode }; } async function generateRecoveryCode(): Promise { const s = await ensureSodium(); const raw = s.randombytes_buf(24); let out = ''; for (let i = 0; i < raw.length; i++) { out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length]; if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-'; } return out; } // Normalizes a user-typed recovery code: strips dashes/spaces, uppercases, // maps look-alike characters. Lets users enter the code with imperfect // spacing without rejecting valid input. export function normalizeRecoveryCode(input: string): string { return input .toUpperCase() .replace(/[\s-]/g, '') .split('') .filter((c) => RECOVERY_ALPHABET.includes(c)) .join(''); }