cjkImport.ts

13.4 KB · raw

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};