725a7e0364
Phase A of the crypto/livekit rust-native migration. Rust side - dryoc crate (pure-rust libsodium-compat, no C toolchain) - Tauri commands: crypto_random_bytes, crypto_secretbox_encrypt/decrypt, crypto_box_keypair, crypto_box_encrypt/decrypt, crypto_box_seal/open, crypto_pwhash — all bit-compatible with libsodium-wrappers-sumo - Commands registered via invoke_handler in lib.rs - All IPC payloads base64-encoded to survive serde_json JS side - lib/nativeCryptoOps.ts exposes pwhashArgon2id + randomBytesAsync plus optional secretbox accelerators for future call-site migration - Native-first, WASM fallback on error or when VITE_USE_NATIVE_CRYPTO is false / in browser preview - Argon2id call-sites migrated: secureFileStore.deriveKey and deviceBackup.deriveKey (covers vault unlock + backup/recovery flows) Impact - Vault unlock: ~1200ms → ~200ms (measured locally, Argon2id moderate) - Per-message AEAD left on WASM-worker path: IPC overhead ~40µs would dominate any native speedup below ~100µs/op - WASM stays installed as graceful fallback so browser-preview builds keep working and a native failure self-heals at runtime
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
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.<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 pwhashArgon2id({
|
||
password: passphrase,
|
||
salt,
|
||
outLen: KEY_LEN,
|
||
preset: 'moderate',
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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<string> {
|
||
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<DeviceBackupPayload> {
|
||
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<BackupBundle> {
|
||
const recoveryCode = await generateRecoveryCode();
|
||
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||
exportDeviceBackup(params),
|
||
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||
]);
|
||
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||
}
|
||
|
||
async function generateRecoveryCode(): Promise<string> {
|
||
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('');
|
||
}
|