/** * Import pipeline for a CJK phrase backup (spec §4.2, §9). * * The order matters for security and UX both: * * 1. Parse and validate the ASCII header line. * 2. Strip header + whitespace to get pure CJK content. * 3. Bootstrap-decode the first 18 chars (= 31 bytes, exactly the header * length) so we can read `remaining_bits` before doing the full decode. * 4. Full `cjkDecode` with the correct remaining_bits. * 5. `extractFields` — layout validation, version/flags check. * 6. BLAKE3 checksum verify. If this fails, stop BEFORE prompting for a * passphrase: a corrupt phrase is never worth asking the user to type * their master phrase for. * 7. Argon2id key derivation using the parameters stored in the header. * 8. AES-256-GCM decrypt. AEAD failure is surfaced as DECRYPT_FAILED. * 9. zstd decompress. * 10. `deserialiseVault` — the entry count from the binary header drives * the loop. Afterwards we cross-check against the ASCII header line's * `entries=N` value (spec §4.2 Step 10). * * Every error path uses the exact error codes listed in §9 so the UI layer * (§8) can map them to the prescribed user-facing copy. */ import { cjkDecode, cjkDecodeBootstrap, isValidPhraseCharset } from "./cjk"; import { AUTH_TAG_LEN, CHECKSUM_LEN, computeBlake3, concatBytes, constantTimeEqual, extractFields, HEADER_LEN, MIN_PAYLOAD_LEN, NONCE_LEN, PAYLOAD_VERSION, type ExtractedFields, } from "./cjkPayload"; import { deriveKdfKey, type Argon2Params } from "./cjkExport"; import { deserialiseVault, type VaultBackupPayload } from "./cjkSerialise"; import { decompressZstd } from "./zstd"; import type { VaultEntry } from "../shared/types"; import type { VaultSettings } from "../background/vault"; // --------------------------------------------------------------------------- // Bounds on the Argon2 parameters we'll accept from a header. A malicious or // corrupt phrase could advertise "400 GB of memory, 1 billion iterations" and // hang the browser before the user could cancel. The ceilings here are above // the spec's ARGON2_HIGH_SECURITY set (256 MB / 4 iter / 4 lanes) with // headroom for future tuning. // --------------------------------------------------------------------------- const ARGON2_MAX_MEMORY_KIB = 1024 * 1024; // 1 GB const ARGON2_MAX_ITERATIONS = 32; const ARGON2_MAX_PARALLELISM = 16; const ARGON2_MIN_MEMORY_KIB = 1024; // 1 MB floor to reject nonsense low values const ARGON2_MIN_ITERATIONS = 1; const ARGON2_MIN_PARALLELISM = 1; // Bootstrap: 18 CJK chars × 14 bits = 252 bits = 31 full bytes + 4 residual // bits. 31 is exactly HEADER_LEN, so the bootstrap yields the complete header // without needing any special handling for the residual bits (they're // discarded by cjkDecodeBootstrap because it doesn't enforce alignment). const BOOTSTRAP_CHAR_COUNT = 18; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface ParsedHeaderLine { version: number; entryCount: number; } export interface ImportResult { entries: VaultEntry[]; settings: VaultSettings; /** License key from the backup, if the backup was a Pro export. */ licenseKey?: string; /** Activation timestamp from the backup license, if present. */ licenseActivatedAt?: number; /** The entry count advertised by the ASCII header line. */ announcedEntryCount: number; /** The entry count recorded in the binary header. */ binaryEntryCount: number; } // --------------------------------------------------------------------------- // Step 1: ASCII header line // --------------------------------------------------------------------------- const HEADER_LINE_RE = /^peach:v(\d+):entries=(\d+):zstd:aes256gcm:argon2id$/; /** * Parse the first line of a phrase. Returns { version, entryCount }. * * Throws INVALID_HEADER for anything unparseable — including wrong cipher * tags, wrong KDF tag, non-numeric entry count, or a version number whose * format is right but whose value we don't support. */ export function parseHeaderLine(line: string): ParsedHeaderLine { const m = HEADER_LINE_RE.exec(line.trim()); if (!m) { throw new Error("INVALID_HEADER"); } const version = Number.parseInt(m[1], 10); const entryCount = Number.parseInt(m[2], 10); if (!Number.isFinite(version) || !Number.isFinite(entryCount)) { throw new Error("INVALID_HEADER"); } return { version, entryCount }; } /** * Split a pasted phrase into its ASCII header line and its pure CJK content * (newlines and any other whitespace stripped from the body). Any empty * lines after the header are tolerated — common copy/paste artefacts. */ export function splitPhrase(phrase: string): { headerLine: string; cjkContent: string; } { const firstNewline = phrase.indexOf("\n"); if (firstNewline === -1) { throw new Error("INVALID_HEADER"); } const headerLine = phrase.slice(0, firstNewline).replace(/\r$/, ""); const body = phrase.slice(firstNewline + 1); // Strip ALL whitespace from the body — spaces, tabs, CR, LF. The encoder // emits 60-char lines joined with "\n" but we don't rely on that here. const cjkContent = body.replace(/\s+/g, ""); return { headerLine, cjkContent }; } /** * Fast pre-flight used by the paste-validator in §8.3. Verifies the header * line parses AND every body character is within the CJK alphabet. Returns * true/false instead of throwing so the UI can show a green/red dot live * as the user pastes. */ export function validatePhraseStructure(phrase: string): boolean { try { const { headerLine, cjkContent } = splitPhrase(phrase); const parsed = parseHeaderLine(headerLine); if (parsed.version !== PAYLOAD_VERSION) return false; if (cjkContent.length === 0) return false; return isValidPhraseCharset(cjkContent); } catch { return false; } } // --------------------------------------------------------------------------- // Steps 2–5: phrase → binary → extracted fields // --------------------------------------------------------------------------- /** * Decode a CJK phrase body to its raw binary payload. Two-pass: * pass A: bootstrap-decode 18 chars to read remaining_bits from the header * pass B: full decode with the correct remaining_bits * * Separated from `extractFields` so the UI can run a cheap structural check * before the (slower) alignment-enforcing full decode. */ export function decodePhraseToBytes(cjkContent: string): Uint8Array { if (cjkContent.length < BOOTSTRAP_CHAR_COUNT) { throw new Error("PAYLOAD_TOO_SHORT"); } // Pass A: get the first 31 bytes — exactly the header. const headerBytes = cjkDecodeBootstrap(cjkContent, BOOTSTRAP_CHAR_COUNT); if (headerBytes.length < HEADER_LEN) { // Impossible in practice — 18 chars give 31 full bytes — but guard for // the `cjkDecodeBootstrap` contract in case the caller changes it. throw new Error("PAYLOAD_TOO_SHORT"); } // Version gate before full decode. Catching UNSUPPORTED_VERSION here lets // us avoid burning a full-decode pass on a phrase we'd reject anyway. const version = headerBytes[0]; if (version !== PAYLOAD_VERSION) { throw new Error(`UNSUPPORTED_VERSION: ${version}`); } // remaining_bits lives at offset 13 (u16 LE). const view = new DataView( headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength ); const remainingBitsValue = view.getUint16(13, true); if (remainingBitsValue > 13) { // Valid values are 0..13 (remainder of totalBits % 14). Anything else // means the header is garbage. throw new Error("INVALID_HEADER"); } // Pass B: full decode. cjkDecode enforces the 8-bit alignment invariant. return cjkDecode(cjkContent, remainingBitsValue); } /** * Verify the BLAKE3 checksum appended to the binary payload. Uses * constant-time equality to avoid leaking how many leading bytes matched. * This runs BEFORE the passphrase prompt so a corrupt or truncated phrase * fails fast — spec §4.2 Step 4. */ export async function verifyIntegrity(binary: Uint8Array): Promise { if (binary.length < MIN_PAYLOAD_LEN) { throw new Error("PAYLOAD_TOO_SHORT"); } const fields = extractFields(binary); const computed = await computeBlake3(fields.payloadForChecksum); if (!constantTimeEqual(computed, fields.storedChecksum)) { throw new Error("CHECKSUM_FAILED"); } return fields; } // --------------------------------------------------------------------------- // Steps 6–10: decrypt, decompress, deserialise // --------------------------------------------------------------------------- function argon2ParamsFromFields(fields: ExtractedFields): Argon2Params { const memoryKiB = fields.argon2Memory; const iterations = fields.argon2Time; const parallelism = fields.argon2Parallel; if ( memoryKiB < ARGON2_MIN_MEMORY_KIB || memoryKiB > ARGON2_MAX_MEMORY_KIB || iterations < ARGON2_MIN_ITERATIONS || iterations > ARGON2_MAX_ITERATIONS || parallelism < ARGON2_MIN_PARALLELISM || parallelism > ARGON2_MAX_PARALLELISM ) { throw new Error("INVALID_HEADER"); } return { memoryKiB, iterations, parallelism }; } async function decryptBinary( key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, authTag: Uint8Array ): Promise { const cryptoKey = await crypto.subtle.importKey( "raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer, { name: "AES-GCM" }, false, ["decrypt"] ); // WebCrypto wants ciphertext || authTag as a single buffer. const combined = concatBytes([ciphertext, authTag]); try { const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce.buffer.slice( nonce.byteOffset, nonce.byteOffset + nonce.byteLength ) as ArrayBuffer, tagLength: 128, }, cryptoKey, combined.buffer.slice( combined.byteOffset, combined.byteOffset + combined.byteLength ) as ArrayBuffer ); return new Uint8Array(plaintext); } catch { // WebCrypto surfaces AEAD failure as a plain OperationError with no // distinguishing info. Per spec §9 we normalise all AEAD failures to a // single DECRYPT_FAILED code — do not leak more than that. throw new Error("DECRYPT_FAILED"); } } /** * Decrypt + decompress + deserialise. Takes the already-verified header * extraction so callers can't accidentally skip the checksum step. The * derived key is zeroed in a `finally` block regardless of success. * Returns the full backup payload — entries AND settings. */ export async function decryptAndDeserialise( fields: ExtractedFields, passphrase: string ): Promise { const params = argon2ParamsFromFields(fields); const key = await deriveKdfKey(passphrase, fields.salt, params); try { const compressed = await decryptBinary( key, fields.nonce, fields.ciphertext, fields.authTag ); let serialised: Uint8Array; try { serialised = await decompressZstd(compressed); } catch { // A corrupt zstd frame after a successful AEAD tag check means the // key was right but the plaintext is damaged. Exceedingly unlikely // given AES-GCM's integrity guarantees, but if it happens we treat // it as a decrypt failure rather than a silent empty vault. throw new Error("DECRYPT_FAILED"); } return deserialiseVault(serialised); } finally { key.fill(0); } } // --------------------------------------------------------------------------- // Top-level orchestration // --------------------------------------------------------------------------- /** * Run the full import pipeline end-to-end. This is the convenience entry * point for tests and for callers that already have the passphrase in hand. * * For the real UI flow, prefer composing `decodePhraseToBytes` + * `verifyIntegrity` up front (to detect corruption before prompting the * user for a passphrase), then calling `decryptAndDeserialise` after the * prompt. The structural enforcement comes from the type signature: you * must hold an `ExtractedFields` to call the second step, and the only * way to get one is via `verifyIntegrity`. */ export async function importFromCJK( phrase: string, passphrase: string ): Promise { const { headerLine, cjkContent } = splitPhrase(phrase); const parsed = parseHeaderLine(headerLine); if (parsed.version !== PAYLOAD_VERSION) { throw new Error(`UNSUPPORTED_VERSION: ${parsed.version}`); } const binary = decodePhraseToBytes(cjkContent); const fields = await verifyIntegrity(binary); // Cross-check: the ASCII header's entry count must match the binary // header's. A mismatch means the phrase was edited across layers — // someone swapped the CJK body but left the ASCII header, or vice versa. if (parsed.entryCount !== fields.entryCount) { throw new Error("HEADER_COUNT_MISMATCH"); } const payload = await decryptAndDeserialise(fields, passphrase); // Final cross-check (spec §4.2 Step 10): the JSON entry count must match // the binary header's entry_count. A mismatch means someone edited one // layer without the other. if (payload.entries.length !== fields.entryCount) { throw new Error("ENTRY_COUNT_MISMATCH"); } return { entries: payload.entries, settings: payload.settings, ...(payload.license?.key ? { licenseKey: payload.license.key, licenseActivatedAt: payload.license.activatedAt } : {}), announcedEntryCount: parsed.entryCount, binaryEntryCount: fields.entryCount, }; } // Re-export types and utilities the UI layer needs export { AUTH_TAG_LEN, CHECKSUM_LEN, HEADER_LEN, MIN_PAYLOAD_LEN, NONCE_LEN, };