/** * Export pipeline for a CJK phrase backup (spec §4.1, §5). * * The 12 steps defined in the spec must execute in order. Each one has a * reason the next one depends on. `exportToCJK` is the public entry point; * the sub-helpers are exported for direct use by the test vectors. */ import { argon2id } from "hash-wasm"; import { cjkEncode } from "./cjk"; import { buildHeader, finalisePayload, NONCE_LEN, PAYLOAD_FLAGS, PAYLOAD_VERSION, remainingBitsForCiphertextLen, SALT_LEN, type HeaderFields, } from "./cjkPayload"; import { serialiseVault } from "./cjkSerialise"; import { compressZstd } from "./zstd"; import type { VaultEntry } from "../shared/types"; import type { VaultSettings } from "../background/vault"; // --------------------------------------------------------------------------- // Argon2id parameter sets // --------------------------------------------------------------------------- export interface Argon2Params { memoryKiB: number; iterations: number; parallelism: number; } /** Default set — ~1 second on modern hardware. */ export const ARGON2_DEFAULT: Argon2Params = { memoryKiB: 65536, iterations: 3, parallelism: 4, }; /** High-security set — ~3 seconds, 256 MB memory. */ export const ARGON2_HIGH_SECURITY: Argon2Params = { memoryKiB: 262144, iterations: 4, parallelism: 4, }; // --------------------------------------------------------------------------- // Low-level helpers used by both the export pipeline and the test vectors // --------------------------------------------------------------------------- export async function deriveKdfKey( passphrase: string, salt: Uint8Array, params: Argon2Params ): Promise { return argon2id({ password: passphrase, salt, parallelism: params.parallelism, iterations: params.iterations, memorySize: params.memoryKiB, hashLength: 32, outputType: "binary", }); } export async function encryptBinary( key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array ): Promise<{ ciphertext: Uint8Array; authTag: Uint8Array }> { const cryptoKey = await crypto.subtle.importKey( "raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"] ); // AES-GCM with no AAD (spec §4.1 Step 6 — the header version byte is // protected by the BLAKE3 checksum, not by AEAD-AAD binding). const combined = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 }, cryptoKey, plaintext.buffer.slice( plaintext.byteOffset, plaintext.byteOffset + plaintext.byteLength ) as ArrayBuffer ); const combinedBytes = new Uint8Array(combined); const ciphertext = combinedBytes.slice(0, combinedBytes.length - 16); const authTag = combinedBytes.slice(combinedBytes.length - 16); return { ciphertext, authTag }; } // --------------------------------------------------------------------------- // Phrase formatting // --------------------------------------------------------------------------- const LINE_WIDTH = 60; function buildHeaderLine(entryCount: number): string { return `peach:v${PAYLOAD_VERSION}:entries=${entryCount}:zstd:aes256gcm:argon2id`; } function countsByType(entries: VaultEntry[]): { logins: number; notes: number; passkeys: number; } { let logins = 0; let notes = 0; let passkeys = 0; for (const e of entries) { if (e.type === "passkey") passkeys += 1; else if (e.type === "note") notes += 1; else logins += 1; } return { logins, notes, passkeys }; } export function formatPhrase(cjkChars: string, entryCount: number): string { const lines: string[] = [buildHeaderLine(entryCount)]; for (let i = 0; i < cjkChars.length; i += LINE_WIDTH) { lines.push(cjkChars.slice(i, i + LINE_WIDTH)); } return lines.join("\n"); } // --------------------------------------------------------------------------- // Top-level export // --------------------------------------------------------------------------- export interface ExportInput { entries: VaultEntry[]; settings?: VaultSettings; passphrase: string; argon2?: Argon2Params; } export interface ExportResult { phrase: string; /** Total entries in the export, including trashed. */ entryCount: number; cjkCharCount: number; /** Active (non-trashed) login count. */ loginCount: number; /** Active (non-trashed) note count. */ noteCount: number; /** Active (non-trashed) passkey count. */ passkeyCount: number; /** Entries with deletedAt set — land back in trash on import. */ trashedCount: number; /** The raw binary that `cjkEncode` processed (checksum appended). Useful for tests. */ binary: Uint8Array; } /** * Run the full export pipeline end-to-end. Includes all entries — active * and trashed — so a restore is a complete, lossless round-trip. Trashed * entries arrive back in trash (deletedAt preserved). `settings` is * round-tripped verbatim so a restore carries theme, auto-lock, etc. */ export async function exportToCJK(input: ExportInput): Promise { const params = input.argon2 ?? ARGON2_DEFAULT; const allEntries = input.entries; const activeEntries = allEntries.filter((e) => !e.deletedAt); const { logins, notes, passkeys } = countsByType(activeEntries); const trashedCount = allEntries.length - activeEntries.length; const serialised = serialiseVault(allEntries, input.settings ?? {}); // Step 3: compress const compressed = await compressZstd(serialised, 19); // Step 4: fresh salt and nonce — independent of the at-rest vault const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); // Step 5: derive key const key = await deriveKdfKey(input.passphrase, salt, params); try { // Step 6: encrypt (no AAD) const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed); // Step 10 (resolved): remaining_bits derives from the final byte count const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length); // Step 7: assemble header const headerFields: HeaderFields = { entryCount: allEntries.length, argon2Memory: params.memoryKiB, argon2Time: params.iterations, argon2Parallel: params.parallelism, salt, remainingBitsValue, }; const header = buildHeader(headerFields); // Step 8+9: finalise (concat + BLAKE3 checksum append) const binary = await finalisePayload(header, nonce, ciphertext, authTag); // Step 11: encode const cjkChars = cjkEncode(binary); // Step 12: format const phrase = formatPhrase(cjkChars, allEntries.length); return { phrase, entryCount: allEntries.length, cjkCharCount: [...cjkChars].length, loginCount: logins, noteCount: notes, passkeyCount: passkeys, trashedCount, binary, }; } finally { // Zero the derived key promptly. The underlying Uint8Array was produced // by hash-wasm and is not referenced anywhere else after encryptBinary. key.fill(0); } } // Re-export flag constants callers may want for assertions export { PAYLOAD_FLAGS, PAYLOAD_VERSION };