cjkImport.ts

13.7 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  /** 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};