peachPayload.ts

34.5 KB · raw

1/**2 * peachPayload.ts — binary payload format shared by PeachScript and the3 * Peach QR matrix code (spec §2).4 *5 *   [HEADER]      8 bytes   — magic "PCHS", version, section count6 *   [SECTION...]  variable  — typed sections, each independently zstd-compressed7 *   [CHECKSUM]    4 bytes   — BLAKE3 (truncated) of all preceding bytes8 *9 * The compressed payload is encrypted with AES-256-GCM under a key derived10 * via Argon2id(passphrase, salt, m=65536 KiB, t=3, p=4):11 *12 *   [SALT 16][NONCE 12][CIPHERTEXT][TAG 16]13 *14 * That envelope is what `buildPayload`/`parsePayload` produce and consume.15 *16 * Sections used:17 *   ENTRIES         (0x01)  — binary-packed vault entries with shared string18 *                              table + delta timestamps + raw UUID bytes19 *                              (login/note/card types); passkeys store as a20 *                              JSON blob within the same section because of21 *                              JWK structural complexity.22 *   SETTINGS        (0x02)  — JSON-encoded VaultSettings23 *   LICENSE         (0x03)  — JSON: { key, activatedAt? }24 *   RECALL          (0x04)  — JSON: manualSummonDomains map25 *   DECLINED_CARDS  (0x05)  — JSON: declined card fingerprints26 *27 * Tampering is detected by AES-GCM's auth tag. The trailing BLAKE3 checksum28 * lives inside the encrypted envelope and exists to flag compressed-payload29 * corruption pre-decompression so we can fail fast with a clear error.30 */3132import { argon2id, blake3 } from "hash-wasm";3334import { compressZstd, decompressZstd } from "./zstd";35import { IMPORT_LIMITS, importTooLarge } from "../shared/import-limits";36import type {37  Canary,38  CardNetwork,39  ManualSummonRecord,40  PeachLicenseExport,41  VaultEntry,42} from "../shared/types";43import type { VaultSettings } from "../background/vault";4445// ---------------------------------------------------------------------------46// Header / section constants47// ---------------------------------------------------------------------------4849export const MAGIC = 0x50434853; // "PCHS"50export const VERSION = 0x01;51export const HEADER_LEN = 8;52export const CHECKSUM_LEN = 4;5354export const SALT_LEN = 16;55export const NONCE_LEN = 12;56export const TAG_LEN = 16;5758// Size buckets (bytes) for payload padding. Coarsens ciphertext-size leakage59// so an attacker cannot infer vault size from file size. The bucket boundaries60// are powers of 4 starting at 1 KiB.61const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;6263// Pad `data` to the next SIZE_BUCKET boundary. The returned Uint8Array is64// zero-filled in the padding region — safe because it lives inside the AEAD.65function padToNearestBucket(data: Uint8Array): Uint8Array {66  const bucket = SIZE_BUCKETS.find((b) => b >= data.length);67  let targetSize: number;68  if (bucket !== undefined) {69    targetSize = bucket;70  } else {71    // Larger than the largest bucket — round up to the next power of two.72    targetSize = 262144;73    while (targetSize < data.length) targetSize *= 2;74  }75  if (targetSize === data.length) return data;76  const padded = new Uint8Array(targetSize);77  padded.set(data);78  return padded;79}8081// Current format byte. Login entries carry an extra `flags2` byte and an82// optional email field.83const FORMAT_V3 = 0x03;8485const ARGON2_MEMORY_KIB = 65536; // 64 MiB86const ARGON2_ITERATIONS = 3;87const ARGON2_PARALLELISM = 4;8889const SECTION_ENTRIES = 0x01;90const SECTION_SETTINGS = 0x02;91const SECTION_LICENSE = 0x03;92const SECTION_RECALL = 0x04;93const SECTION_DECLINED_CARDS = 0x05;9495const ENTRY_TYPE_LOGIN = 0x01;96const ENTRY_TYPE_NOTE = 0x02;97const ENTRY_TYPE_CARD = 0x03;98const ENTRY_TYPE_PASSKEY = 0x04;99100// Login entry flag bits101const F_HAS_TOTP = 1 << 0;102const F_HAS_TAGS = 1 << 1;103const F_HAS_NOTES = 1 << 2;104const F_HAS_CANARY = 1 << 3;105const F_HAS_PWCHANGED = 1 << 4;106const F_HAS_BREACH = 1 << 5;107const F_HAS_ADDITIONAL_URLS = 1 << 6;108const F_HAS_DELETED = 1 << 7;109110// v3-only flags2 byte (login entries only). Reserves room for further111// post-v3.6.82 fields without another envelope bump.112//113// CAUTION: flags2 bits are read positionally within FORMAT_V3. Adding a new114// boolean flag bit here does NOT require a format bump. But adding a new115// VARIABLE-LENGTH field gated by a new flag bit DOES — older v3 decoders don't116// know to skip the new field and the read cursor desyncs for everything after117// it. If you need a new variable-length field, bump FORMAT_V3 → FORMAT_V4 and118// gate the decoder on the format byte. Only add single-bit booleans to flags2.119const F2_HAS_EMAIL = 1 << 0;120121const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];122123// ---------------------------------------------------------------------------124// Public API125// ---------------------------------------------------------------------------126127export interface BuildPayloadInput {128  entries: VaultEntry[];129  settings: VaultSettings;130  license?: PeachLicenseExport;131  /** Optional declined card fingerprints (subset of settings — split out per spec). */132  declinedCardFingerprints?: string[];133  /** Optional recall map (subset of settings — split out per spec). */134  manualSummonDomains?: Record<string, ManualSummonRecord>;135  passphrase: string;136}137138export interface ParsePayloadOutput {139  entries: VaultEntry[];140  settings: VaultSettings;141  license?: PeachLicenseExport;142  declinedCardFingerprints?: string[];143  manualSummonDomains?: Record<string, ManualSummonRecord>;144}145146/**147 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →148 * AES-256-GCM encrypt under an Argon2id-derived key. Returns149 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into150 * `encodePeachScript` or the QR matrix encoder.151 */152export async function buildPayload(153  input: BuildPayloadInput,154): Promise<Uint8Array> {155  const sections: Uint8Array[] = [];156157  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));158  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));159  if (input.license) {160    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));161  }162  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {163    sections.push(164      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))165    );166  }167  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {168    sections.push(169      await buildSection(170        SECTION_DECLINED_CARDS,171        encodeJsonSection(input.declinedCardFingerprints)172      )173    );174  }175176  const header = new Uint8Array(HEADER_LEN);177  const headerView = new DataView(header.buffer);178  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump179  header[4] = VERSION;180  header[5] = sections.length;181  // bytes 6,7: reserved (zero)182183  const body = concat([header, ...sections]);184  const checksum = await blake3Truncated(body);185  const inner = concat([body, checksum]);186187  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]188  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and189  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent190  // wrong-password failure. Same envelope as v2; v3 adds optional email +191  // flags2 byte on login entries (see encodeEntry/decodeEntry).192  const header5 = new Uint8Array(5);193  header5[0] = FORMAT_V3;194  new DataView(header5.buffer).setUint32(1, inner.length, true);195  const wrapped = padToNearestBucket(concat([header5, inner]));196197  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));198  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));199  const key = await deriveKey(input.passphrase, salt);200  try {201    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);202    return concat([salt, nonce, ciphertext, tag]);203  } finally {204    key.fill(0);205  }206}207208/**209 * Parse the encrypted envelope back to a typed payload. Throws210 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a211 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or212 * `CHECKSUM_FAILED` on a corrupt inner payload.213 */214export async function parsePayload(215  envelope: Uint8Array,216  passphrase: string217): Promise<ParsePayloadOutput> {218  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {219    throw new Error("PAYLOAD_TOO_SHORT");220  }221222  const salt = envelope.subarray(0, SALT_LEN);223  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);224  const tag = envelope.subarray(envelope.length - TAG_LEN);225  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);226227  let decrypted: Uint8Array;228  const key = await deriveKey(passphrase, salt);229  try {230    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);231  } catch {232    throw new Error("INVALID_PASSPHRASE");233  } finally {234    key.fill(0);235  }236237  // Current envelopes always carry the version byte and length prefix.238  if (decrypted.length < 5 || decrypted[0] !== FORMAT_V3) {239    throw new Error("UNSUPPORTED_VERSION");240  }241  const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);242  if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");243  const inner = decrypted.subarray(5, 5 + innerLen);244245  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");246247  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);248  const stored = inner.subarray(inner.length - CHECKSUM_LEN);249  const computed = await blake3Truncated(body);250  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");251252  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);253  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");254  const version = body[4];255  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);256  const sectionCount = body[5];257258  let cursor = HEADER_LEN;259  const out: ParsePayloadOutput = {260    entries: [],261    settings: {},262  };263  let totalDecompressed = 0;264  for (let i = 0; i < sectionCount; i++) {265    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");266    const type = body[cursor];267    const length = view.getUint32(cursor + 1, true);268    const clength = view.getUint32(cursor + 5, true);269    // MV-4: `length` is attacker-declared. Refuse before decompressing —270    // never let a 100-byte section promise (and zstd deliver) gigabytes.271    if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {272      throw importTooLarge(273        `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,274      );275    }276    // MV-4: cap the total decompressed budget across all sections too, so277    // many maximal sections can't multiply past the single-section cap.278    totalDecompressed += length;279    if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {280      throw importTooLarge("total decompressed payload exceeds limit");281    }282    const dataStart = cursor + 9;283    const dataEnd = dataStart + clength;284    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");285    const compressed = body.subarray(dataStart, dataEnd);286    const data = await decompressZstd(compressed);287    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");288289    switch (type) {290      case SECTION_ENTRIES:291        out.entries = decodeEntriesSection(data, FORMAT_V3);292        break;293      case SECTION_SETTINGS:294        out.settings = decodeJsonSection<VaultSettings>(data);295        break;296      case SECTION_LICENSE:297        out.license = decodeJsonSection<PeachLicenseExport>(data);298        break;299      case SECTION_RECALL:300        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);301        break;302      case SECTION_DECLINED_CARDS:303        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);304        break;305      default:306        // Unknown section — skip rather than fail, so future-introduced307        // sections do not break older clients reading newer payloads.308        break;309    }310311    cursor = dataEnd;312  }313314  return out;315}316317// ---------------------------------------------------------------------------318// Section assembly319// ---------------------------------------------------------------------------320321async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {322  const compressed = await compressZstd(raw, 19);323  const out = new Uint8Array(9 + compressed.length);324  const view = new DataView(out.buffer);325  out[0] = type;326  view.setUint32(1, raw.length, true);327  view.setUint32(5, compressed.length, true);328  out.set(compressed, 9);329  return out;330}331332function encodeJsonSection(value: unknown): Uint8Array {333  return new TextEncoder().encode(JSON.stringify(value));334}335336function decodeJsonSection<T>(data: Uint8Array): T {337  return JSON.parse(new TextDecoder().decode(data)) as T;338}339340// ---------------------------------------------------------------------------341// Entries section: shared string table + per-type packed bodies342// ---------------------------------------------------------------------------343344class StringTableBuilder {345  private map = new Map<string, number>();346  private list: string[] = [];347348  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */349  intern(value: string | undefined): number {350    if (!value) return 0;351    const existing = this.map.get(value);352    if (existing !== undefined) return existing;353    const idx = this.list.length + 1; // 1-indexed; 0 = absent354    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");355    this.map.set(value, idx);356    this.list.push(value);357    return idx;358  }359360  serialize(): Uint8Array {361    const enc = new TextEncoder();362    const encoded = this.list.map((s) => enc.encode(s));363    let total = 2; // u16 count364    for (const e of encoded) total += 2 + e.length;365    const out = new Uint8Array(total);366    const view = new DataView(out.buffer);367    let cursor = 0;368    view.setUint16(cursor, encoded.length, true);369    cursor += 2;370    for (const e of encoded) {371      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");372      view.setUint16(cursor, e.length, true);373      cursor += 2;374      out.set(e, cursor);375      cursor += e.length;376    }377    return out;378  }379}380381function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {382  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);383  const count = view.getUint16(cursor, true);384  cursor += 2;385  const strings: string[] = [];386  const dec = new TextDecoder();387  for (let i = 0; i < count; i++) {388    const len = view.getUint16(cursor, true);389    cursor += 2;390    strings.push(dec.decode(data.subarray(cursor, cursor + len)));391    cursor += len;392  }393  return { strings, cursor };394}395396function lookupString(strings: string[], idx: number): string | undefined {397  if (idx === 0) return undefined;398  return strings[idx - 1];399}400401function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {402  const table = new StringTableBuilder();403  const bodies: Uint8Array[] = [];404405  // Pre-intern title for every entry so the table is built consistently.406  for (const entry of entries) table.intern(entry.title);407408  for (const entry of entries) {409    bodies.push(encodeEntry(entry, table, formatVersion));410  }411412  const tableBytes = table.serialize();413414  // [u32 count][string_table][entries...]415  let bodiesLen = 0;416  for (const b of bodies) bodiesLen += b.length;417  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);418  const view = new DataView(out.buffer);419  view.setUint32(0, entries.length, true);420  out.set(tableBytes, 4);421  let cursor = 4 + tableBytes.length;422  for (const b of bodies) {423    out.set(b, cursor);424    cursor += b.length;425  }426  return out;427}428429function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {430  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);431  const count = view.getUint32(0, true);432  const { strings, cursor: afterTable } = readStringTable(data, 4);433434  const out: VaultEntry[] = [];435  let cursor = afterTable;436  for (let i = 0; i < count; i++) {437    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);438    out.push(entry);439    cursor = next;440  }441  return out;442}443444// ---------------------------------------------------------------------------445// Per-entry packing446// ---------------------------------------------------------------------------447448function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {449  const writer = new BinaryWriter();450  const type = entryTypeByte(entry);451  writer.u8(type);452  writer.bytes(uuidToBytes(entry.id));453454  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx455  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);456  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);457  const updatedDelta = Math.max(0, updatedSec - createdSec);458  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;459460  // flags placeholder (we'll overwrite at the end)461  const flagOffset = writer.length;462  writer.u8(0);463  // v3-only flags2 placeholder, written for every entry so the section464  // layout stays uniform across types.465  let flags2Offset = -1;466  if (formatVersion === FORMAT_V3) {467    flags2Offset = writer.length;468    writer.u8(0);469  }470471  writer.u32(createdSec);472  writer.u32(updatedDelta);473  writer.u32(deletedSec);474  writer.u16(table.intern(entry.title));475476  let flags = 0;477  let flags2 = 0;478  if (entry.deletedAt) flags |= F_HAS_DELETED;479480  if (type === ENTRY_TYPE_LOGIN) {481    writer.u16(table.intern(entry.url));482    writer.u16(table.intern(entry.username));483    writer.lpString(entry.password ?? "", 1);484    if (formatVersion === FORMAT_V3 && entry.email) {485      flags2 |= F2_HAS_EMAIL;486      writer.lpString(entry.email, 1);487    }488489    if (entry.totp) {490      flags |= F_HAS_TOTP;491      writer.lpString(entry.totp, 1);492    }493    if (entry.tags && entry.tags.length > 0) {494      flags |= F_HAS_TAGS;495      writer.u8(Math.min(entry.tags.length, 0xff));496      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {497        writer.u16(table.intern(entry.tags[i]));498      }499    }500    if (entry.notes) {501      flags |= F_HAS_NOTES;502      writer.u16(table.intern(entry.notes));503    }504    if (entry.canary) {505      flags |= F_HAS_CANARY;506      writer.u16(table.intern(entry.canary.webhookUrl));507      writer.u16(table.intern(entry.canary.label));508    }509    if (entry.passwordChangedAt) {510      flags |= F_HAS_PWCHANGED;511      const pwSec = Math.floor(entry.passwordChangedAt / 1000);512      writer.u32(Math.max(0, pwSec - createdSec));513    }514    if (typeof entry.breach === "number" && entry.breach > 0) {515      flags |= F_HAS_BREACH;516      writer.u32(entry.breach);517    }518    if (entry.additionalUrls && entry.additionalUrls.length > 0) {519      flags |= F_HAS_ADDITIONAL_URLS;520      writer.u8(Math.min(entry.additionalUrls.length, 0xff));521      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {522        writer.u16(table.intern(entry.additionalUrls[i]));523      }524    }525  } else if (type === ENTRY_TYPE_NOTE) {526    if (entry.notes) {527      flags |= F_HAS_NOTES;528      writer.u16(table.intern(entry.notes));529    }530    if (entry.tags && entry.tags.length > 0) {531      flags |= F_HAS_TAGS;532      writer.u8(Math.min(entry.tags.length, 0xff));533      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {534        writer.u16(table.intern(entry.tags[i]));535      }536    }537  } else if (type === ENTRY_TYPE_CARD) {538    writer.u16(table.intern(entry.cardholderName));539    writer.lpString(entry.cardNumber ?? "", 1);540    writer.lpString(entry.expMonth ?? "", 1);541    writer.lpString(entry.expYear ?? "", 1);542    writer.lpString(entry.cvc ?? "", 1);543    writer.u16(table.intern(entry.cardNickname));544    writer.u8(networkToByte(entry.network));545    writer.u16(table.intern(entry.last4));546    if (entry.tags && entry.tags.length > 0) {547      flags |= F_HAS_TAGS;548      writer.u8(Math.min(entry.tags.length, 0xff));549      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {550        writer.u16(table.intern(entry.tags[i]));551      }552    }553    if (entry.notes) {554      flags |= F_HAS_NOTES;555      writer.u16(table.intern(entry.notes));556    }557  } else if (type === ENTRY_TYPE_PASSKEY) {558    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.559    const pk = entry.passkey;560    if (!pk) throw new Error("INVALID_PASSKEY_ENTRY");561    const blob = encodeJsonSection({562      passkey: pk,563      // Preserve the full login field set for passkey-bearing login entries.564      ...({565        ...(entry.password          !== undefined ? { password:          entry.password } : {}),566        ...(entry.url               !== undefined ? { url:               entry.url } : {}),567        ...(entry.username          !== undefined ? { username:          entry.username } : {}),568        ...(entry.email             !== undefined ? { email:             entry.email } : {}),569        ...(entry.totp              !== undefined ? { totp:              entry.totp } : {}),570        ...(entry.notes             !== undefined ? { notes:             entry.notes } : {}),571        ...(entry.tags              !== undefined ? { tags:              entry.tags } : {}),572        ...(entry.canary            !== undefined ? { canary:            entry.canary } : {}),573        ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),574        ...(entry.breach            !== undefined ? { breach:            entry.breach } : {}),575        ...(entry.additionalUrls    !== undefined ? { additionalUrls:    entry.additionalUrls } : {}),576      }),577    });578    writer.u32(blob.length);579    writer.bytes(blob);580  }581582  writer.setU8(flagOffset, flags);583  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);584  return writer.toBytes();585}586587function decodeEntry(588  data: Uint8Array,589  start: number,590  strings: string[],591  formatVersion: number592): { entry: VaultEntry; cursor: number } {593  const reader = new BinaryReader(data, start);594  const type = reader.u8();595  const id = bytesToUuid(reader.read(16));596  const flags = reader.u8();597  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;598  const createdSec = reader.u32();599  const updatedDelta = reader.u32();600  const deletedSec = reader.u32();601  const titleIdx = reader.u16();602603  const createdAt = createdSec * 1000;604  const modifiedAt = (createdSec + updatedDelta) * 1000;605  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;606  const title = lookupString(strings, titleIdx) ?? "";607608  const baseEntry: Partial<VaultEntry> = {609    id,610    title,611    createdAt,612    modifiedAt,613    ...(deletedAt !== undefined ? { deletedAt } : {}),614  };615616  if (type === ENTRY_TYPE_LOGIN) {617    const urlIdx = reader.u16();618    const usernameIdx = reader.u16();619    const password = reader.lpString(1);620    const tail: Partial<VaultEntry> = {};621622    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);623624    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);625    if (flags & F_HAS_TAGS) {626      const cnt = reader.u8();627      const tags: string[] = [];628      for (let i = 0; i < cnt; i++) {629        const v = lookupString(strings, reader.u16());630        if (v !== undefined) tags.push(v);631      }632      tail.tags = tags;633    }634    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());635    if (flags & F_HAS_CANARY) {636      const webhookUrl = lookupString(strings, reader.u16()) ?? "";637      const label = lookupString(strings, reader.u16());638      const canary: Canary = { webhookUrl };639      if (label !== undefined) canary.label = label;640      tail.canary = canary;641    }642    if (flags & F_HAS_PWCHANGED) {643      const pwDelta = reader.u32();644      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;645    }646    if (flags & F_HAS_BREACH) tail.breach = reader.u32();647    if (flags & F_HAS_ADDITIONAL_URLS) {648      const cnt = reader.u8();649      const urls: string[] = [];650      for (let i = 0; i < cnt; i++) {651        const v = lookupString(strings, reader.u16());652        if (v !== undefined) urls.push(v);653      }654      tail.additionalUrls = urls;655    }656657    return {658      entry: {659        ...(baseEntry as VaultEntry),660        type: "login",661        url: lookupString(strings, urlIdx),662        username: lookupString(strings, usernameIdx),663        password,664        ...tail,665      } as VaultEntry,666      cursor: reader.cursor,667    };668  }669670  if (type === ENTRY_TYPE_NOTE) {671    const tail: Partial<VaultEntry> = {};672    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());673    if (flags & F_HAS_TAGS) {674      const cnt = reader.u8();675      const tags: string[] = [];676      for (let i = 0; i < cnt; i++) {677        const v = lookupString(strings, reader.u16());678        if (v !== undefined) tags.push(v);679      }680      tail.tags = tags;681    }682    return {683      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,684      cursor: reader.cursor,685    };686  }687688  if (type === ENTRY_TYPE_CARD) {689    const cardholderIdx = reader.u16();690    const cardNumber = reader.lpString(1);691    const expMonth = reader.lpString(1);692    const expYear = reader.lpString(1);693    const cvc = reader.lpString(1);694    const nicknameIdx = reader.u16();695    const network = networkFromByte(reader.u8());696    const last4Idx = reader.u16();697    const tail: Partial<VaultEntry> = {};698    if (flags & F_HAS_TAGS) {699      const cnt = reader.u8();700      const tags: string[] = [];701      for (let i = 0; i < cnt; i++) {702        const v = lookupString(strings, reader.u16());703        if (v !== undefined) tags.push(v);704      }705      tail.tags = tags;706    }707    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());708709    return {710      entry: {711        ...(baseEntry as VaultEntry),712        type: "card",713        cardholderName: lookupString(strings, cardholderIdx),714        cardNumber,715        expMonth,716        expYear,717        cvc,718        cardNickname: lookupString(strings, nicknameIdx),719        network,720        last4: lookupString(strings, last4Idx),721        ...tail,722      } as VaultEntry,723      cursor: reader.cursor,724    };725  }726727  if (type === ENTRY_TYPE_PASSKEY) {728    const blobLen = reader.u32();729    const blob = reader.read(blobLen);730    const obj = decodeJsonSection<Record<string, unknown>>(blob);731    // MV-6: pick known passkey-bearing fields only — `...obj` let an732    // attacker-authored blob inject arbitrary keys past the section codec.733    // The full nested-shape validation happens in vault.ts734    // sanitizeRestoredEntry (every import path funnels through it); this735    // picker just stops unknown top-level keys at the decode boundary.736    const picked: Partial<VaultEntry> = {};737    const allow = [738      "passkey", "username", "email", "password", "url", "additionalUrls",739      "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",740      "canary", "deletedAt", "breach",741    ] as const;742    for (const k of allow) {743      if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];744    }745    return {746      entry: {747        ...(baseEntry as VaultEntry),748        type: "passkey",749        ...picked,750      } as VaultEntry,751      cursor: reader.cursor,752    };753  }754755  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);756}757758function entryTypeByte(entry: VaultEntry): number {759  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys760  // as type:"login" entries with a nested passkey sub-object, so the type761  // field alone is not sufficient to detect them.762  if (entry.passkey) return ENTRY_TYPE_PASSKEY;763  switch (entry.type) {764    case "note":765      return ENTRY_TYPE_NOTE;766    case "card":767      return ENTRY_TYPE_CARD;768    case "passkey":769      return ENTRY_TYPE_PASSKEY;770    default:771      return ENTRY_TYPE_LOGIN;772  }773}774775function networkToByte(network: CardNetwork | undefined): number {776  if (!network) return 0xff;777  const idx = NETWORK_VALUES.indexOf(network);778  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");779}780781function networkFromByte(b: number): CardNetwork | undefined {782  if (b === 0xff) return undefined;783  return NETWORK_VALUES[b] ?? "unknown";784}785786// ---------------------------------------------------------------------------787// UUID packing — 16 raw bytes vs 36-character hyphenated string788// ---------------------------------------------------------------------------789790export function uuidToBytes(uuid: string): Uint8Array {791  const hex = uuid.replace(/-/g, "");792  if (hex.length !== 32) throw new Error("UUID_FORMAT");793  const out = new Uint8Array(16);794  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);795  return out;796}797798export function bytesToUuid(bytes: Uint8Array): string {799  if (bytes.length !== 16) throw new Error("UUID_LENGTH");800  const hex = Array.from(bytes)801    .map((b) => b.toString(16).padStart(2, "0"))802    .join("");803  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;804}805806// ---------------------------------------------------------------------------807// Tiny binary reader/writer808// ---------------------------------------------------------------------------809810class BinaryWriter {811  private chunks: Uint8Array[] = [];812  private len = 0;813814  get length(): number {815    return this.len;816  }817818  u8(v: number): void {819    const b = new Uint8Array(1);820    b[0] = v & 0xff;821    this.push(b);822  }823824  setU8(offset: number, v: number): void {825    // Only used to back-patch the flags byte; always lands inside chunks[1] or826    // earlier. Reconstruct by walking chunks until we hit the offset.827    let cursor = 0;828    for (const c of this.chunks) {829      if (offset < cursor + c.length) {830        c[offset - cursor] = v & 0xff;831        return;832      }833      cursor += c.length;834    }835    throw new Error("setU8 out of range");836  }837838  u16(v: number): void {839    const b = new Uint8Array(2);840    new DataView(b.buffer).setUint16(0, v & 0xffff, true);841    this.push(b);842  }843844  u32(v: number): void {845    const b = new Uint8Array(4);846    new DataView(b.buffer).setUint32(0, v >>> 0, true);847    this.push(b);848  }849850  bytes(b: Uint8Array): void {851    this.push(b);852  }853854  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */855  lpString(value: string, lenSize: 1 | 2): void {856    const enc = new TextEncoder().encode(value);857    if (lenSize === 1) {858      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");859      this.u8(enc.length);860    } else {861      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");862      this.u16(enc.length);863    }864    this.push(enc);865  }866867  toBytes(): Uint8Array {868    const out = new Uint8Array(this.len);869    let cursor = 0;870    for (const c of this.chunks) {871      out.set(c, cursor);872      cursor += c.length;873    }874    return out;875  }876877  private push(b: Uint8Array): void {878    this.chunks.push(b);879    this.len += b.length;880  }881}882883// Exported for the PCH-02-008 bounds-check regression tests only — not part884// of the payload API surface.885export class BinaryReader {886  cursor: number;887  private view: DataView;888  constructor(private data: Uint8Array, start: number) {889    this.cursor = start;890    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);891  }892  // PCH-02-008: fail truncated imported payloads with the decoder's typed893  // PAYLOAD_TOO_SHORT error, not undefined/NaN reads or engine RangeErrors.894  private ensure(n: number): void {895    if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");896  }897  u8(): number {898    this.ensure(1);899    return this.data[this.cursor++];900  }901  u16(): number {902    this.ensure(2);903    const v = this.view.getUint16(this.cursor, true);904    this.cursor += 2;905    return v;906  }907  u32(): number {908    this.ensure(4);909    const v = this.view.getUint32(this.cursor, true);910    this.cursor += 4;911    return v;912  }913  read(n: number): Uint8Array {914    this.ensure(n);915    const out = this.data.subarray(this.cursor, this.cursor + n);916    this.cursor += n;917    return out;918  }919  lpString(lenSize: 1 | 2): string {920    const len = lenSize === 1 ? this.u8() : this.u16();921    return new TextDecoder().decode(this.read(len));922  }923}924925// ---------------------------------------------------------------------------926// Crypto helpers927// ---------------------------------------------------------------------------928929async function deriveKey(930  passphrase: string,931  salt: Uint8Array,932): Promise<Uint8Array> {933  const pw = passphrase.normalize("NFC");934  return argon2id({935    password: pw,936    salt,937    parallelism: ARGON2_PARALLELISM,938    iterations: ARGON2_ITERATIONS,939    memorySize: ARGON2_MEMORY_KIB,940    hashLength: 32,941    outputType: "binary",942  });943}944945async function aesGcmEncrypt(946  key: Uint8Array,947  nonce: Uint8Array,948  plaintext: Uint8Array949): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {950  const cryptoKey = await crypto.subtle.importKey(951    "raw",952    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,953    { name: "AES-GCM" },954    false,955    ["encrypt"]956  );957  const raw = await crypto.subtle.encrypt(958    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },959    cryptoKey,960    plaintext.buffer.slice(961      plaintext.byteOffset,962      plaintext.byteOffset + plaintext.byteLength963    ) as ArrayBuffer964  );965  const out = new Uint8Array(raw);966  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };967}968969async function aesGcmDecrypt(970  key: Uint8Array,971  nonce: Uint8Array,972  ciphertext: Uint8Array,973  tag: Uint8Array974): Promise<Uint8Array> {975  const cryptoKey = await crypto.subtle.importKey(976    "raw",977    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,978    { name: "AES-GCM" },979    false,980    ["decrypt"]981  );982  const combined = concat([ciphertext, tag]);983  const raw = await crypto.subtle.decrypt(984    {985      name: "AES-GCM",986      iv: nonce.buffer.slice(987        nonce.byteOffset,988        nonce.byteOffset + nonce.byteLength989      ) as ArrayBuffer,990      tagLength: 128,991    },992    cryptoKey,993    combined.buffer.slice(994      combined.byteOffset,995      combined.byteOffset + combined.byteLength996    ) as ArrayBuffer997  );998  return new Uint8Array(raw);999}10001001async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1002  const hex = await blake3(data);1003  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.1004  const out = new Uint8Array(CHECKSUM_LEN);1005  for (let i = 0; i < CHECKSUM_LEN; i++) {1006    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1007  }1008  return out;1009}10101011function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1012  if (a.length !== b.length) return false;1013  let diff = 0;1014  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1015  return diff === 0;1016}10171018function concat(parts: Uint8Array[]): Uint8Array {1019  let total = 0;1020  for (const p of parts) total += p.length;1021  const out = new Uint8Array(total);1022  let cursor = 0;1023  for (const p of parts) {1024    out.set(p, cursor);1025    cursor += p.length;1026  }1027  return out;1028}