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); } }