cjkImport.ts
1/**2 * Import pipeline for a CJK phrase backup (spec §4.2, §9).3 *4 * The order matters for security and UX both:5 *6 * 1. Parse and validate the ASCII header line.7 * 2. Strip header + whitespace to get pure CJK content.8 * 3. Bootstrap-decode the first 18 chars (= 31 bytes, exactly the header9 * length) so we can read `remaining_bits` before doing the full decode.10 * 4. Full `cjkDecode` with the correct remaining_bits.11 * 5. `extractFields` — layout validation, version/flags check.12 * 6. BLAKE3 checksum verify. If this fails, stop BEFORE prompting for a13 * passphrase: a corrupt phrase is never worth asking the user to type14 * their master phrase for.15 * 7. Argon2id key derivation using the parameters stored in the header.16 * 8. AES-256-GCM decrypt. AEAD failure is surfaced as DECRYPT_FAILED.17 * 9. zstd decompress.18 * 10. `deserialiseVault` — the entry count from the binary header drives19 * the loop. Afterwards we cross-check against the ASCII header line's20 * `entries=N` value (spec §4.2 Step 10).21 *22 * Every error path uses the exact error codes listed in §9 so the UI layer23 * (§8) can map them to the prescribed user-facing copy.24 */2526import { cjkDecode, cjkDecodeBootstrap, isValidPhraseCharset } from "./cjk";27import {28 AUTH_TAG_LEN,29 CHECKSUM_LEN,30 computeBlake3,31 concatBytes,32 constantTimeEqual,33 extractFields,34 HEADER_LEN,35 MIN_PAYLOAD_LEN,36 NONCE_LEN,37 PAYLOAD_VERSION,38 type ExtractedFields,39} from "./cjkPayload";40import { deriveKdfKey, type Argon2Params } from "./cjkExport";41import { deserialiseVault, type VaultBackupPayload } from "./cjkSerialise";42import { decompressZstd } from "./zstd";43import type { VaultEntry } from "../shared/types";44import type { VaultSettings } from "../background/vault";4546// ---------------------------------------------------------------------------47// Bounds on the Argon2 parameters we'll accept from a header. A malicious or48// corrupt phrase could advertise "400 GB of memory, 1 billion iterations" and49// hang the browser before the user could cancel. The ceilings here are above50// the spec's ARGON2_HIGH_SECURITY set (256 MB / 4 iter / 4 lanes) with51// headroom for future tuning.52// ---------------------------------------------------------------------------5354const ARGON2_MAX_MEMORY_KIB = 1024 * 1024; // 1 GB55const ARGON2_MAX_ITERATIONS = 32;56const ARGON2_MAX_PARALLELISM = 16;57const ARGON2_MIN_MEMORY_KIB = 1024; // 1 MB floor to reject nonsense low values58const ARGON2_MIN_ITERATIONS = 1;59const ARGON2_MIN_PARALLELISM = 1;6061// Bootstrap: 18 CJK chars × 14 bits = 252 bits = 31 full bytes + 4 residual62// bits. 31 is exactly HEADER_LEN, so the bootstrap yields the complete header63// without needing any special handling for the residual bits (they're64// discarded by cjkDecodeBootstrap because it doesn't enforce alignment).65const BOOTSTRAP_CHAR_COUNT = 18;6667// ---------------------------------------------------------------------------68// Public types69// ---------------------------------------------------------------------------7071export interface ParsedHeaderLine {72 version: number;73 entryCount: number;74}7576export interface ImportResult {77 entries: VaultEntry[];78 settings: VaultSettings;79 /** The entry count advertised by the ASCII header line. */80 announcedEntryCount: number;81 /** The entry count recorded in the binary header. */82 binaryEntryCount: number;83}8485// ---------------------------------------------------------------------------86// Step 1: ASCII header line87// ---------------------------------------------------------------------------8889const HEADER_LINE_RE =90 /^peach:v(\d+):entries=(\d+):zstd:aes256gcm:argon2id$/;9192/**93 * Parse the first line of a phrase. Returns { version, entryCount }.94 *95 * Throws INVALID_HEADER for anything unparseable — including wrong cipher96 * tags, wrong KDF tag, non-numeric entry count, or a version number whose97 * format is right but whose value we don't support.98 */99export function parseHeaderLine(line: string): ParsedHeaderLine {100 const m = HEADER_LINE_RE.exec(line.trim());101 if (!m) {102 throw new Error("INVALID_HEADER");103 }104 const version = Number.parseInt(m[1], 10);105 const entryCount = Number.parseInt(m[2], 10);106 if (!Number.isFinite(version) || !Number.isFinite(entryCount)) {107 throw new Error("INVALID_HEADER");108 }109 return { version, entryCount };110}111112/**113 * Split a pasted phrase into its ASCII header line and its pure CJK content114 * (newlines and any other whitespace stripped from the body). Any empty115 * lines after the header are tolerated — common copy/paste artefacts.116 */117export function splitPhrase(phrase: string): {118 headerLine: string;119 cjkContent: string;120} {121 const firstNewline = phrase.indexOf("\n");122 if (firstNewline === -1) {123 throw new Error("INVALID_HEADER");124 }125 const headerLine = phrase.slice(0, firstNewline).replace(/\r$/, "");126 const body = phrase.slice(firstNewline + 1);127 // Strip ALL whitespace from the body — spaces, tabs, CR, LF. The encoder128 // emits 60-char lines joined with "\n" but we don't rely on that here.129 const cjkContent = body.replace(/\s+/g, "");130 return { headerLine, cjkContent };131}132133/**134 * Fast pre-flight used by the paste-validator in §8.3. Verifies the header135 * line parses AND every body character is within the CJK alphabet. Returns136 * true/false instead of throwing so the UI can show a green/red dot live137 * as the user pastes.138 */139export function validatePhraseStructure(phrase: string): boolean {140 try {141 const { headerLine, cjkContent } = splitPhrase(phrase);142 const parsed = parseHeaderLine(headerLine);143 if (parsed.version !== PAYLOAD_VERSION) return false;144 if (cjkContent.length === 0) return false;145 return isValidPhraseCharset(cjkContent);146 } catch {147 return false;148 }149}150151// ---------------------------------------------------------------------------152// Steps 2–5: phrase → binary → extracted fields153// ---------------------------------------------------------------------------154155/**156 * Decode a CJK phrase body to its raw binary payload. Two-pass:157 * pass A: bootstrap-decode 18 chars to read remaining_bits from the header158 * pass B: full decode with the correct remaining_bits159 *160 * Separated from `extractFields` so the UI can run a cheap structural check161 * before the (slower) alignment-enforcing full decode.162 */163export function decodePhraseToBytes(cjkContent: string): Uint8Array {164 if (cjkContent.length < BOOTSTRAP_CHAR_COUNT) {165 throw new Error("PAYLOAD_TOO_SHORT");166 }167168 // Pass A: get the first 31 bytes — exactly the header.169 const headerBytes = cjkDecodeBootstrap(cjkContent, BOOTSTRAP_CHAR_COUNT);170 if (headerBytes.length < HEADER_LEN) {171 // Impossible in practice — 18 chars give 31 full bytes — but guard for172 // the `cjkDecodeBootstrap` contract in case the caller changes it.173 throw new Error("PAYLOAD_TOO_SHORT");174 }175176 // Version gate before full decode. Catching UNSUPPORTED_VERSION here lets177 // us avoid burning a full-decode pass on a phrase we'd reject anyway.178 const version = headerBytes[0];179 if (version !== PAYLOAD_VERSION) {180 throw new Error(`UNSUPPORTED_VERSION: ${version}`);181 }182183 // remaining_bits lives at offset 13 (u16 LE).184 const view = new DataView(185 headerBytes.buffer,186 headerBytes.byteOffset,187 headerBytes.byteLength188 );189 const remainingBitsValue = view.getUint16(13, true);190 if (remainingBitsValue > 13) {191 // Valid values are 0..13 (remainder of totalBits % 14). Anything else192 // means the header is garbage.193 throw new Error("INVALID_HEADER");194 }195196 // Pass B: full decode. cjkDecode enforces the 8-bit alignment invariant.197 return cjkDecode(cjkContent, remainingBitsValue);198}199200/**201 * Verify the BLAKE3 checksum appended to the binary payload. Uses202 * constant-time equality to avoid leaking how many leading bytes matched.203 * This runs BEFORE the passphrase prompt so a corrupt or truncated phrase204 * fails fast — spec §4.2 Step 4.205 */206export async function verifyIntegrity(binary: Uint8Array): Promise<ExtractedFields> {207 if (binary.length < MIN_PAYLOAD_LEN) {208 throw new Error("PAYLOAD_TOO_SHORT");209 }210 const fields = extractFields(binary);211 const computed = await computeBlake3(fields.payloadForChecksum);212 if (!constantTimeEqual(computed, fields.storedChecksum)) {213 throw new Error("CHECKSUM_FAILED");214 }215 return fields;216}217218// ---------------------------------------------------------------------------219// Steps 6–10: decrypt, decompress, deserialise220// ---------------------------------------------------------------------------221222function argon2ParamsFromFields(fields: ExtractedFields): Argon2Params {223 const memoryKiB = fields.argon2Memory;224 const iterations = fields.argon2Time;225 const parallelism = fields.argon2Parallel;226227 if (228 memoryKiB < ARGON2_MIN_MEMORY_KIB ||229 memoryKiB > ARGON2_MAX_MEMORY_KIB ||230 iterations < ARGON2_MIN_ITERATIONS ||231 iterations > ARGON2_MAX_ITERATIONS ||232 parallelism < ARGON2_MIN_PARALLELISM ||233 parallelism > ARGON2_MAX_PARALLELISM234 ) {235 throw new Error("INVALID_HEADER");236 }237238 return { memoryKiB, iterations, parallelism };239}240241async function decryptBinary(242 key: Uint8Array,243 nonce: Uint8Array,244 ciphertext: Uint8Array,245 authTag: Uint8Array246): Promise<Uint8Array> {247 const cryptoKey = await crypto.subtle.importKey(248 "raw",249 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,250 { name: "AES-GCM" },251 false,252 ["decrypt"]253 );254255 // WebCrypto wants ciphertext || authTag as a single buffer.256 const combined = concatBytes([ciphertext, authTag]);257258 try {259 const plaintext = await crypto.subtle.decrypt(260 {261 name: "AES-GCM",262 iv: nonce.buffer.slice(263 nonce.byteOffset,264 nonce.byteOffset + nonce.byteLength265 ) as ArrayBuffer,266 tagLength: 128,267 },268 cryptoKey,269 combined.buffer.slice(270 combined.byteOffset,271 combined.byteOffset + combined.byteLength272 ) as ArrayBuffer273 );274 return new Uint8Array(plaintext);275 } catch {276 // WebCrypto surfaces AEAD failure as a plain OperationError with no277 // distinguishing info. Per spec §9 we normalise all AEAD failures to a278 // single DECRYPT_FAILED code — do not leak more than that.279 throw new Error("DECRYPT_FAILED");280 }281}282283/**284 * Decrypt + decompress + deserialise. Takes the already-verified header285 * extraction so callers can't accidentally skip the checksum step. The286 * derived key is zeroed in a `finally` block regardless of success.287 * Returns the full backup payload — entries AND settings.288 */289export async function decryptAndDeserialise(290 fields: ExtractedFields,291 passphrase: string292): Promise<VaultBackupPayload> {293 const params = argon2ParamsFromFields(fields);294 const key = await deriveKdfKey(passphrase, fields.salt, params);295 try {296 const compressed = await decryptBinary(297 key,298 fields.nonce,299 fields.ciphertext,300 fields.authTag301 );302303 let serialised: Uint8Array;304 try {305 serialised = await decompressZstd(compressed);306 } catch {307 // A corrupt zstd frame after a successful AEAD tag check means the308 // key was right but the plaintext is damaged. Exceedingly unlikely309 // given AES-GCM's integrity guarantees, but if it happens we treat310 // it as a decrypt failure rather than a silent empty vault.311 throw new Error("DECRYPT_FAILED");312 }313314 return deserialiseVault(serialised);315 } finally {316 key.fill(0);317 }318}319320// ---------------------------------------------------------------------------321// Top-level orchestration322// ---------------------------------------------------------------------------323324/**325 * Run the full import pipeline end-to-end. This is the convenience entry326 * point for tests and for callers that already have the passphrase in hand.327 *328 * For the real UI flow, prefer composing `decodePhraseToBytes` +329 * `verifyIntegrity` up front (to detect corruption before prompting the330 * user for a passphrase), then calling `decryptAndDeserialise` after the331 * prompt. The structural enforcement comes from the type signature: you332 * must hold an `ExtractedFields` to call the second step, and the only333 * way to get one is via `verifyIntegrity`.334 */335export async function importFromCJK(336 phrase: string,337 passphrase: string338): Promise<ImportResult> {339 const { headerLine, cjkContent } = splitPhrase(phrase);340 const parsed = parseHeaderLine(headerLine);341342 if (parsed.version !== PAYLOAD_VERSION) {343 throw new Error(`UNSUPPORTED_VERSION: ${parsed.version}`);344 }345346 const binary = decodePhraseToBytes(cjkContent);347 const fields = await verifyIntegrity(binary);348349 // Cross-check: the ASCII header's entry count must match the binary350 // header's. A mismatch means the phrase was edited across layers —351 // someone swapped the CJK body but left the ASCII header, or vice versa.352 if (parsed.entryCount !== fields.entryCount) {353 throw new Error("HEADER_COUNT_MISMATCH");354 }355356 const payload = await decryptAndDeserialise(fields, passphrase);357358 // Final cross-check (spec §4.2 Step 10): the JSON entry count must match359 // the binary header's entry_count. A mismatch means someone edited one360 // layer without the other.361 if (payload.entries.length !== fields.entryCount) {362 throw new Error("ENTRY_COUNT_MISMATCH");363 }364365 return {366 entries: payload.entries,367 settings: payload.settings,368 announcedEntryCount: parsed.entryCount,369 binaryEntryCount: fields.entryCount,370 };371}372373// Re-export types and utilities the UI layer needs374export {375 AUTH_TAG_LEN,376 CHECKSUM_LEN,377 HEADER_LEN,378 MIN_PAYLOAD_LEN,379 NONCE_LEN,380};