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 /** License key from the backup, if the backup was a Pro export. */80 licenseKey?: string;81 /** Activation timestamp from the backup license, if present. */82 licenseActivatedAt?: number;83 /** The entry count advertised by the ASCII header line. */84 announcedEntryCount: number;85 /** The entry count recorded in the binary header. */86 binaryEntryCount: number;87}8889// ---------------------------------------------------------------------------90// Step 1: ASCII header line91// ---------------------------------------------------------------------------9293const HEADER_LINE_RE =94 /^peach:v(\d+):entries=(\d+):zstd:aes256gcm:argon2id$/;9596/**97 * Parse the first line of a phrase. Returns { version, entryCount }.98 *99 * Throws INVALID_HEADER for anything unparseable — including wrong cipher100 * tags, wrong KDF tag, non-numeric entry count, or a version number whose101 * format is right but whose value we don't support.102 */103export function parseHeaderLine(line: string): ParsedHeaderLine {104 const m = HEADER_LINE_RE.exec(line.trim());105 if (!m) {106 throw new Error("INVALID_HEADER");107 }108 const version = Number.parseInt(m[1], 10);109 const entryCount = Number.parseInt(m[2], 10);110 if (!Number.isFinite(version) || !Number.isFinite(entryCount)) {111 throw new Error("INVALID_HEADER");112 }113 return { version, entryCount };114}115116/**117 * Split a pasted phrase into its ASCII header line and its pure CJK content118 * (newlines and any other whitespace stripped from the body). Any empty119 * lines after the header are tolerated — common copy/paste artefacts.120 */121export function splitPhrase(phrase: string): {122 headerLine: string;123 cjkContent: string;124} {125 const firstNewline = phrase.indexOf("\n");126 if (firstNewline === -1) {127 throw new Error("INVALID_HEADER");128 }129 const headerLine = phrase.slice(0, firstNewline).replace(/\r$/, "");130 const body = phrase.slice(firstNewline + 1);131 // Strip ALL whitespace from the body — spaces, tabs, CR, LF. The encoder132 // emits 60-char lines joined with "\n" but we don't rely on that here.133 const cjkContent = body.replace(/\s+/g, "");134 return { headerLine, cjkContent };135}136137/**138 * Fast pre-flight used by the paste-validator in §8.3. Verifies the header139 * line parses AND every body character is within the CJK alphabet. Returns140 * true/false instead of throwing so the UI can show a green/red dot live141 * as the user pastes.142 */143export function validatePhraseStructure(phrase: string): boolean {144 try {145 const { headerLine, cjkContent } = splitPhrase(phrase);146 const parsed = parseHeaderLine(headerLine);147 if (parsed.version !== PAYLOAD_VERSION) return false;148 if (cjkContent.length === 0) return false;149 return isValidPhraseCharset(cjkContent);150 } catch {151 return false;152 }153}154155// ---------------------------------------------------------------------------156// Steps 2–5: phrase → binary → extracted fields157// ---------------------------------------------------------------------------158159/**160 * Decode a CJK phrase body to its raw binary payload. Two-pass:161 * pass A: bootstrap-decode 18 chars to read remaining_bits from the header162 * pass B: full decode with the correct remaining_bits163 *164 * Separated from `extractFields` so the UI can run a cheap structural check165 * before the (slower) alignment-enforcing full decode.166 */167export function decodePhraseToBytes(cjkContent: string): Uint8Array {168 if (cjkContent.length < BOOTSTRAP_CHAR_COUNT) {169 throw new Error("PAYLOAD_TOO_SHORT");170 }171172 // Pass A: get the first 31 bytes — exactly the header.173 const headerBytes = cjkDecodeBootstrap(cjkContent, BOOTSTRAP_CHAR_COUNT);174 if (headerBytes.length < HEADER_LEN) {175 // Impossible in practice — 18 chars give 31 full bytes — but guard for176 // the `cjkDecodeBootstrap` contract in case the caller changes it.177 throw new Error("PAYLOAD_TOO_SHORT");178 }179180 // Version gate before full decode. Catching UNSUPPORTED_VERSION here lets181 // us avoid burning a full-decode pass on a phrase we'd reject anyway.182 const version = headerBytes[0];183 if (version !== PAYLOAD_VERSION) {184 throw new Error(`UNSUPPORTED_VERSION: ${version}`);185 }186187 // remaining_bits lives at offset 13 (u16 LE).188 const view = new DataView(189 headerBytes.buffer,190 headerBytes.byteOffset,191 headerBytes.byteLength192 );193 const remainingBitsValue = view.getUint16(13, true);194 if (remainingBitsValue > 13) {195 // Valid values are 0..13 (remainder of totalBits % 14). Anything else196 // means the header is garbage.197 throw new Error("INVALID_HEADER");198 }199200 // Pass B: full decode. cjkDecode enforces the 8-bit alignment invariant.201 return cjkDecode(cjkContent, remainingBitsValue);202}203204/**205 * Verify the BLAKE3 checksum appended to the binary payload. Uses206 * constant-time equality to avoid leaking how many leading bytes matched.207 * This runs BEFORE the passphrase prompt so a corrupt or truncated phrase208 * fails fast — spec §4.2 Step 4.209 */210export async function verifyIntegrity(binary: Uint8Array): Promise<ExtractedFields> {211 if (binary.length < MIN_PAYLOAD_LEN) {212 throw new Error("PAYLOAD_TOO_SHORT");213 }214 const fields = extractFields(binary);215 const computed = await computeBlake3(fields.payloadForChecksum);216 if (!constantTimeEqual(computed, fields.storedChecksum)) {217 throw new Error("CHECKSUM_FAILED");218 }219 return fields;220}221222// ---------------------------------------------------------------------------223// Steps 6–10: decrypt, decompress, deserialise224// ---------------------------------------------------------------------------225226function argon2ParamsFromFields(fields: ExtractedFields): Argon2Params {227 const memoryKiB = fields.argon2Memory;228 const iterations = fields.argon2Time;229 const parallelism = fields.argon2Parallel;230231 if (232 memoryKiB < ARGON2_MIN_MEMORY_KIB ||233 memoryKiB > ARGON2_MAX_MEMORY_KIB ||234 iterations < ARGON2_MIN_ITERATIONS ||235 iterations > ARGON2_MAX_ITERATIONS ||236 parallelism < ARGON2_MIN_PARALLELISM ||237 parallelism > ARGON2_MAX_PARALLELISM238 ) {239 throw new Error("INVALID_HEADER");240 }241242 return { memoryKiB, iterations, parallelism };243}244245async function decryptBinary(246 key: Uint8Array,247 nonce: Uint8Array,248 ciphertext: Uint8Array,249 authTag: Uint8Array250): Promise<Uint8Array> {251 const cryptoKey = await crypto.subtle.importKey(252 "raw",253 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,254 { name: "AES-GCM" },255 false,256 ["decrypt"]257 );258259 // WebCrypto wants ciphertext || authTag as a single buffer.260 const combined = concatBytes([ciphertext, authTag]);261262 try {263 const plaintext = await crypto.subtle.decrypt(264 {265 name: "AES-GCM",266 iv: nonce.buffer.slice(267 nonce.byteOffset,268 nonce.byteOffset + nonce.byteLength269 ) as ArrayBuffer,270 tagLength: 128,271 },272 cryptoKey,273 combined.buffer.slice(274 combined.byteOffset,275 combined.byteOffset + combined.byteLength276 ) as ArrayBuffer277 );278 return new Uint8Array(plaintext);279 } catch {280 // WebCrypto surfaces AEAD failure as a plain OperationError with no281 // distinguishing info. Per spec §9 we normalise all AEAD failures to a282 // single DECRYPT_FAILED code — do not leak more than that.283 throw new Error("DECRYPT_FAILED");284 }285}286287/**288 * Decrypt + decompress + deserialise. Takes the already-verified header289 * extraction so callers can't accidentally skip the checksum step. The290 * derived key is zeroed in a `finally` block regardless of success.291 * Returns the full backup payload — entries AND settings.292 */293export async function decryptAndDeserialise(294 fields: ExtractedFields,295 passphrase: string296): Promise<VaultBackupPayload> {297 const params = argon2ParamsFromFields(fields);298 const key = await deriveKdfKey(passphrase, fields.salt, params);299 try {300 const compressed = await decryptBinary(301 key,302 fields.nonce,303 fields.ciphertext,304 fields.authTag305 );306307 let serialised: Uint8Array;308 try {309 serialised = await decompressZstd(compressed);310 } catch {311 // A corrupt zstd frame after a successful AEAD tag check means the312 // key was right but the plaintext is damaged. Exceedingly unlikely313 // given AES-GCM's integrity guarantees, but if it happens we treat314 // it as a decrypt failure rather than a silent empty vault.315 throw new Error("DECRYPT_FAILED");316 }317318 return deserialiseVault(serialised);319 } finally {320 key.fill(0);321 }322}323324// ---------------------------------------------------------------------------325// Top-level orchestration326// ---------------------------------------------------------------------------327328/**329 * Run the full import pipeline end-to-end. This is the convenience entry330 * point for tests and for callers that already have the passphrase in hand.331 *332 * For the real UI flow, prefer composing `decodePhraseToBytes` +333 * `verifyIntegrity` up front (to detect corruption before prompting the334 * user for a passphrase), then calling `decryptAndDeserialise` after the335 * prompt. The structural enforcement comes from the type signature: you336 * must hold an `ExtractedFields` to call the second step, and the only337 * way to get one is via `verifyIntegrity`.338 */339export async function importFromCJK(340 phrase: string,341 passphrase: string342): Promise<ImportResult> {343 const { headerLine, cjkContent } = splitPhrase(phrase);344 const parsed = parseHeaderLine(headerLine);345346 if (parsed.version !== PAYLOAD_VERSION) {347 throw new Error(`UNSUPPORTED_VERSION: ${parsed.version}`);348 }349350 const binary = decodePhraseToBytes(cjkContent);351 const fields = await verifyIntegrity(binary);352353 // Cross-check: the ASCII header's entry count must match the binary354 // header's. A mismatch means the phrase was edited across layers —355 // someone swapped the CJK body but left the ASCII header, or vice versa.356 if (parsed.entryCount !== fields.entryCount) {357 throw new Error("HEADER_COUNT_MISMATCH");358 }359360 const payload = await decryptAndDeserialise(fields, passphrase);361362 // Final cross-check (spec §4.2 Step 10): the JSON entry count must match363 // the binary header's entry_count. A mismatch means someone edited one364 // layer without the other.365 if (payload.entries.length !== fields.entryCount) {366 throw new Error("ENTRY_COUNT_MISMATCH");367 }368369 return {370 entries: payload.entries,371 settings: payload.settings,372 ...(payload.license?.key ? { licenseKey: payload.license.key, licenseActivatedAt: payload.license.activatedAt } : {}),373 announcedEntryCount: parsed.entryCount,374 binaryEntryCount: fields.entryCount,375 };376}377378// Re-export types and utilities the UI layer needs379export {380 AUTH_TAG_LEN,381 CHECKSUM_LEN,382 HEADER_LEN,383 MIN_PAYLOAD_LEN,384 NONCE_LEN,385};