peachPayload.ts

34.7 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// V3 added login flags2/email. V4 widens only login passwords to a u1682// byte-length prefix. Readers retain V3 compatibility; writers emit V4.83const FORMAT_V3 = 0x03;84const FORMAT_V4 = 0x04;8586const ARGON2_MEMORY_KIB = 65536; // 64 MiB87const ARGON2_ITERATIONS = 3;88const ARGON2_PARALLELISM = 4;8990const SECTION_ENTRIES = 0x01;91const SECTION_SETTINGS = 0x02;92const SECTION_LICENSE = 0x03;93const SECTION_RECALL = 0x04;94const SECTION_DECLINED_CARDS = 0x05;9596const ENTRY_TYPE_LOGIN = 0x01;97const ENTRY_TYPE_NOTE = 0x02;98const ENTRY_TYPE_CARD = 0x03;99const ENTRY_TYPE_PASSKEY = 0x04;100101// Login entry flag bits102const F_HAS_TOTP = 1 << 0;103const F_HAS_TAGS = 1 << 1;104const F_HAS_NOTES = 1 << 2;105const F_HAS_CANARY = 1 << 3;106const F_HAS_PWCHANGED = 1 << 4;107const F_HAS_BREACH = 1 << 5;108const F_HAS_ADDITIONAL_URLS = 1 << 6;109const F_HAS_DELETED = 1 << 7;110111// v3-only flags2 byte (login entries only). Reserves room for further112// post-v3.6.82 fields without another envelope bump.113//114// CAUTION: flags2 bits are read positionally within FORMAT_V3. Adding a new115// boolean flag bit here does NOT require a format bump. But adding a new116// VARIABLE-LENGTH field gated by a new flag bit DOES — older v3 decoders don't117// know to skip the new field and the read cursor desyncs for everything after118// it. If you need a new variable-length field, bump FORMAT_V3 → FORMAT_V4 and119// gate the decoder on the format byte. Only add single-bit booleans to flags2.120const F2_HAS_EMAIL = 1 << 0;121122const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];123124// ---------------------------------------------------------------------------125// Public API126// ---------------------------------------------------------------------------127128export interface BuildPayloadInput {129  entries: VaultEntry[];130  settings: VaultSettings;131  license?: PeachLicenseExport;132  /** Optional declined card fingerprints (subset of settings — split out per spec). */133  declinedCardFingerprints?: string[];134  /** Optional recall map (subset of settings — split out per spec). */135  manualSummonDomains?: Record<string, ManualSummonRecord>;136  passphrase: string;137}138139export interface ParsePayloadOutput {140  entries: VaultEntry[];141  settings: VaultSettings;142  license?: PeachLicenseExport;143  declinedCardFingerprints?: string[];144  manualSummonDomains?: Record<string, ManualSummonRecord>;145}146147/**148 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →149 * AES-256-GCM encrypt under an Argon2id-derived key. Returns150 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into151 * `encodePeachScript` or the QR matrix encoder.152 */153export async function buildPayload(154  input: BuildPayloadInput,155): Promise<Uint8Array> {156  const sections: Uint8Array[] = [];157158  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V4)));159  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));160  if (input.license) {161    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));162  }163  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {164    sections.push(165      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))166    );167  }168  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {169    sections.push(170      await buildSection(171        SECTION_DECLINED_CARDS,172        encodeJsonSection(input.declinedCardFingerprints)173      )174    );175  }176177  const header = new Uint8Array(HEADER_LEN);178  const headerView = new DataView(header.buffer);179  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump180  header[4] = VERSION;181  header[5] = sections.length;182  // bytes 6,7: reserved (zero)183184  const body = concat([header, ...sections]);185  const checksum = await blake3Truncated(body);186  const inner = concat([body, checksum]);187188  // v4 format: [FORMAT_V4(1)][inner_len(4 LE)][inner][zero padding to bucket]189  // Old clients see FORMAT_V4 (0x04) where they expect PCHS magic (0x50) and190  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent191  // wrong-password failure. V4 keeps the v3 layout except for the widened192  // login-password length prefix (see encodeEntry/decodeEntry).193  const header5 = new Uint8Array(5);194  header5[0] = FORMAT_V4;195  new DataView(header5.buffer).setUint32(1, inner.length, true);196  const wrapped = padToNearestBucket(concat([header5, inner]));197198  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));199  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));200  const key = await deriveKey(input.passphrase, salt);201  try {202    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);203    return concat([salt, nonce, ciphertext, tag]);204  } finally {205    key.fill(0);206  }207}208209/**210 * Parse the encrypted envelope back to a typed payload. Throws211 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a212 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or213 * `CHECKSUM_FAILED` on a corrupt inner payload.214 */215export async function parsePayload(216  envelope: Uint8Array,217  passphrase: string218): Promise<ParsePayloadOutput> {219  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {220    throw new Error("PAYLOAD_TOO_SHORT");221  }222223  const salt = envelope.subarray(0, SALT_LEN);224  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);225  const tag = envelope.subarray(envelope.length - TAG_LEN);226  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);227228  let decrypted: Uint8Array;229  const key = await deriveKey(passphrase, salt);230  try {231    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);232  } catch {233    throw new Error("INVALID_PASSPHRASE");234  } finally {235    key.fill(0);236  }237238  // Current envelopes always carry the version byte and length prefix.239  if (decrypted.length < 5 || (decrypted[0] !== FORMAT_V3 && decrypted[0] !== FORMAT_V4)) {240    throw new Error("UNSUPPORTED_VERSION");241  }242  const formatVersion = decrypted[0];243  const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);244  if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");245  const inner = decrypted.subarray(5, 5 + innerLen);246247  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");248249  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);250  const stored = inner.subarray(inner.length - CHECKSUM_LEN);251  const computed = await blake3Truncated(body);252  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");253254  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);255  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");256  const version = body[4];257  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);258  const sectionCount = body[5];259260  let cursor = HEADER_LEN;261  const out: ParsePayloadOutput = {262    entries: [],263    settings: {},264  };265  let totalDecompressed = 0;266  for (let i = 0; i < sectionCount; i++) {267    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");268    const type = body[cursor];269    const length = view.getUint32(cursor + 1, true);270    const clength = view.getUint32(cursor + 5, true);271    // MV-4: `length` is attacker-declared. Refuse before decompressing —272    // never let a 100-byte section promise (and zstd deliver) gigabytes.273    if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {274      throw importTooLarge(275        `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,276      );277    }278    // MV-4: cap the total decompressed budget across all sections too, so279    // many maximal sections can't multiply past the single-section cap.280    totalDecompressed += length;281    if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {282      throw importTooLarge("total decompressed payload exceeds limit");283    }284    const dataStart = cursor + 9;285    const dataEnd = dataStart + clength;286    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");287    const compressed = body.subarray(dataStart, dataEnd);288    const data = await decompressZstd(compressed);289    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");290291    switch (type) {292      case SECTION_ENTRIES:293        out.entries = decodeEntriesSection(data, formatVersion);294        break;295      case SECTION_SETTINGS:296        out.settings = decodeJsonSection<VaultSettings>(data);297        break;298      case SECTION_LICENSE:299        out.license = decodeJsonSection<PeachLicenseExport>(data);300        break;301      case SECTION_RECALL:302        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);303        break;304      case SECTION_DECLINED_CARDS:305        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);306        break;307      default:308        // Unknown section — skip rather than fail, so future-introduced309        // sections do not break older clients reading newer payloads.310        break;311    }312313    cursor = dataEnd;314  }315316  return out;317}318319// ---------------------------------------------------------------------------320// Section assembly321// ---------------------------------------------------------------------------322323async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {324  const compressed = await compressZstd(raw, 19);325  const out = new Uint8Array(9 + compressed.length);326  const view = new DataView(out.buffer);327  out[0] = type;328  view.setUint32(1, raw.length, true);329  view.setUint32(5, compressed.length, true);330  out.set(compressed, 9);331  return out;332}333334function encodeJsonSection(value: unknown): Uint8Array {335  return new TextEncoder().encode(JSON.stringify(value));336}337338function decodeJsonSection<T>(data: Uint8Array): T {339  return JSON.parse(new TextDecoder().decode(data)) as T;340}341342// ---------------------------------------------------------------------------343// Entries section: shared string table + per-type packed bodies344// ---------------------------------------------------------------------------345346class StringTableBuilder {347  private map = new Map<string, number>();348  private list: string[] = [];349350  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */351  intern(value: string | undefined): number {352    if (!value) return 0;353    const existing = this.map.get(value);354    if (existing !== undefined) return existing;355    const idx = this.list.length + 1; // 1-indexed; 0 = absent356    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");357    this.map.set(value, idx);358    this.list.push(value);359    return idx;360  }361362  serialize(): Uint8Array {363    const enc = new TextEncoder();364    const encoded = this.list.map((s) => enc.encode(s));365    let total = 2; // u16 count366    for (const e of encoded) total += 2 + e.length;367    const out = new Uint8Array(total);368    const view = new DataView(out.buffer);369    let cursor = 0;370    view.setUint16(cursor, encoded.length, true);371    cursor += 2;372    for (const e of encoded) {373      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");374      view.setUint16(cursor, e.length, true);375      cursor += 2;376      out.set(e, cursor);377      cursor += e.length;378    }379    return out;380  }381}382383function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {384  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);385  const count = view.getUint16(cursor, true);386  cursor += 2;387  const strings: string[] = [];388  const dec = new TextDecoder();389  for (let i = 0; i < count; i++) {390    const len = view.getUint16(cursor, true);391    cursor += 2;392    strings.push(dec.decode(data.subarray(cursor, cursor + len)));393    cursor += len;394  }395  return { strings, cursor };396}397398function lookupString(strings: string[], idx: number): string | undefined {399  if (idx === 0) return undefined;400  return strings[idx - 1];401}402403function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {404  const table = new StringTableBuilder();405  const bodies: Uint8Array[] = [];406407  // Pre-intern title for every entry so the table is built consistently.408  for (const entry of entries) table.intern(entry.title);409410  for (const entry of entries) {411    bodies.push(encodeEntry(entry, table, formatVersion));412  }413414  const tableBytes = table.serialize();415416  // [u32 count][string_table][entries...]417  let bodiesLen = 0;418  for (const b of bodies) bodiesLen += b.length;419  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);420  const view = new DataView(out.buffer);421  view.setUint32(0, entries.length, true);422  out.set(tableBytes, 4);423  let cursor = 4 + tableBytes.length;424  for (const b of bodies) {425    out.set(b, cursor);426    cursor += b.length;427  }428  return out;429}430431function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {432  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);433  const count = view.getUint32(0, true);434  const { strings, cursor: afterTable } = readStringTable(data, 4);435436  const out: VaultEntry[] = [];437  let cursor = afterTable;438  for (let i = 0; i < count; i++) {439    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);440    out.push(entry);441    cursor = next;442  }443  return out;444}445446// ---------------------------------------------------------------------------447// Per-entry packing448// ---------------------------------------------------------------------------449450function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {451  const writer = new BinaryWriter();452  const type = entryTypeByte(entry);453  writer.u8(type);454  writer.bytes(uuidToBytes(entry.id));455456  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx457  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);458  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);459  const updatedDelta = Math.max(0, updatedSec - createdSec);460  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;461462  // flags placeholder (we'll overwrite at the end)463  const flagOffset = writer.length;464  writer.u8(0);465  // v3+ flags2 placeholder, written for every entry so the section466  // layout stays uniform across types.467  let flags2Offset = -1;468  if (formatVersion >= FORMAT_V3) {469    flags2Offset = writer.length;470    writer.u8(0);471  }472473  writer.u32(createdSec);474  writer.u32(updatedDelta);475  writer.u32(deletedSec);476  writer.u16(table.intern(entry.title));477478  let flags = 0;479  let flags2 = 0;480  if (entry.deletedAt) flags |= F_HAS_DELETED;481482  if (type === ENTRY_TYPE_LOGIN) {483    writer.u16(table.intern(entry.url));484    writer.u16(table.intern(entry.username));485    writer.lpString(entry.password ?? "", formatVersion >= FORMAT_V4 ? 2 : 1);486    if (formatVersion >= FORMAT_V3 && entry.email) {487      flags2 |= F2_HAS_EMAIL;488      writer.lpString(entry.email, 1);489    }490491    if (entry.totp) {492      flags |= F_HAS_TOTP;493      writer.lpString(entry.totp, 1);494    }495    if (entry.tags && entry.tags.length > 0) {496      flags |= F_HAS_TAGS;497      writer.u8(Math.min(entry.tags.length, 0xff));498      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {499        writer.u16(table.intern(entry.tags[i]));500      }501    }502    if (entry.notes) {503      flags |= F_HAS_NOTES;504      writer.u16(table.intern(entry.notes));505    }506    if (entry.canary) {507      flags |= F_HAS_CANARY;508      writer.u16(table.intern(entry.canary.webhookUrl));509      writer.u16(table.intern(entry.canary.label));510    }511    if (entry.passwordChangedAt) {512      flags |= F_HAS_PWCHANGED;513      const pwSec = Math.floor(entry.passwordChangedAt / 1000);514      writer.u32(Math.max(0, pwSec - createdSec));515    }516    if (typeof entry.breach === "number" && entry.breach > 0) {517      flags |= F_HAS_BREACH;518      writer.u32(entry.breach);519    }520    if (entry.additionalUrls && entry.additionalUrls.length > 0) {521      flags |= F_HAS_ADDITIONAL_URLS;522      writer.u8(Math.min(entry.additionalUrls.length, 0xff));523      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {524        writer.u16(table.intern(entry.additionalUrls[i]));525      }526    }527  } else if (type === ENTRY_TYPE_NOTE) {528    if (entry.notes) {529      flags |= F_HAS_NOTES;530      writer.u16(table.intern(entry.notes));531    }532    if (entry.tags && entry.tags.length > 0) {533      flags |= F_HAS_TAGS;534      writer.u8(Math.min(entry.tags.length, 0xff));535      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {536        writer.u16(table.intern(entry.tags[i]));537      }538    }539  } else if (type === ENTRY_TYPE_CARD) {540    writer.u16(table.intern(entry.cardholderName));541    writer.lpString(entry.cardNumber ?? "", 1);542    writer.lpString(entry.expMonth ?? "", 1);543    writer.lpString(entry.expYear ?? "", 1);544    writer.lpString(entry.cvc ?? "", 1);545    writer.u16(table.intern(entry.cardNickname));546    writer.u8(networkToByte(entry.network));547    writer.u16(table.intern(entry.last4));548    if (entry.tags && entry.tags.length > 0) {549      flags |= F_HAS_TAGS;550      writer.u8(Math.min(entry.tags.length, 0xff));551      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {552        writer.u16(table.intern(entry.tags[i]));553      }554    }555    if (entry.notes) {556      flags |= F_HAS_NOTES;557      writer.u16(table.intern(entry.notes));558    }559  } else if (type === ENTRY_TYPE_PASSKEY) {560    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.561    const pk = entry.passkey;562    if (!pk) throw new Error("INVALID_PASSKEY_ENTRY");563    const blob = encodeJsonSection({564      passkey: pk,565      // Preserve the full login field set for passkey-bearing login entries.566      ...({567        ...(entry.password          !== undefined ? { password:          entry.password } : {}),568        ...(entry.url               !== undefined ? { url:               entry.url } : {}),569        ...(entry.username          !== undefined ? { username:          entry.username } : {}),570        ...(entry.email             !== undefined ? { email:             entry.email } : {}),571        ...(entry.totp              !== undefined ? { totp:              entry.totp } : {}),572        ...(entry.notes             !== undefined ? { notes:             entry.notes } : {}),573        ...(entry.tags              !== undefined ? { tags:              entry.tags } : {}),574        ...(entry.canary            !== undefined ? { canary:            entry.canary } : {}),575        ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),576        ...(entry.breach            !== undefined ? { breach:            entry.breach } : {}),577        ...(entry.additionalUrls    !== undefined ? { additionalUrls:    entry.additionalUrls } : {}),578      }),579    });580    writer.u32(blob.length);581    writer.bytes(blob);582  }583584  writer.setU8(flagOffset, flags);585  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);586  return writer.toBytes();587}588589function decodeEntry(590  data: Uint8Array,591  start: number,592  strings: string[],593  formatVersion: number594): { entry: VaultEntry; cursor: number } {595  const reader = new BinaryReader(data, start);596  const type = reader.u8();597  const id = bytesToUuid(reader.read(16));598  const flags = reader.u8();599  const flags2 = formatVersion >= FORMAT_V3 ? reader.u8() : 0;600  const createdSec = reader.u32();601  const updatedDelta = reader.u32();602  const deletedSec = reader.u32();603  const titleIdx = reader.u16();604605  const createdAt = createdSec * 1000;606  const modifiedAt = (createdSec + updatedDelta) * 1000;607  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;608  const title = lookupString(strings, titleIdx) ?? "";609610  const baseEntry: Partial<VaultEntry> = {611    id,612    title,613    createdAt,614    modifiedAt,615    ...(deletedAt !== undefined ? { deletedAt } : {}),616  };617618  if (type === ENTRY_TYPE_LOGIN) {619    const urlIdx = reader.u16();620    const usernameIdx = reader.u16();621    const password = reader.lpString(formatVersion >= FORMAT_V4 ? 2 : 1);622    const tail: Partial<VaultEntry> = {};623624    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);625626    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);627    if (flags & F_HAS_TAGS) {628      const cnt = reader.u8();629      const tags: string[] = [];630      for (let i = 0; i < cnt; i++) {631        const v = lookupString(strings, reader.u16());632        if (v !== undefined) tags.push(v);633      }634      tail.tags = tags;635    }636    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());637    if (flags & F_HAS_CANARY) {638      const webhookUrl = lookupString(strings, reader.u16()) ?? "";639      const label = lookupString(strings, reader.u16());640      const canary: Canary = { webhookUrl };641      if (label !== undefined) canary.label = label;642      tail.canary = canary;643    }644    if (flags & F_HAS_PWCHANGED) {645      const pwDelta = reader.u32();646      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;647    }648    if (flags & F_HAS_BREACH) tail.breach = reader.u32();649    if (flags & F_HAS_ADDITIONAL_URLS) {650      const cnt = reader.u8();651      const urls: string[] = [];652      for (let i = 0; i < cnt; i++) {653        const v = lookupString(strings, reader.u16());654        if (v !== undefined) urls.push(v);655      }656      tail.additionalUrls = urls;657    }658659    return {660      entry: {661        ...(baseEntry as VaultEntry),662        type: "login",663        url: lookupString(strings, urlIdx),664        username: lookupString(strings, usernameIdx),665        password,666        ...tail,667      } as VaultEntry,668      cursor: reader.cursor,669    };670  }671672  if (type === ENTRY_TYPE_NOTE) {673    const tail: Partial<VaultEntry> = {};674    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());675    if (flags & F_HAS_TAGS) {676      const cnt = reader.u8();677      const tags: string[] = [];678      for (let i = 0; i < cnt; i++) {679        const v = lookupString(strings, reader.u16());680        if (v !== undefined) tags.push(v);681      }682      tail.tags = tags;683    }684    return {685      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,686      cursor: reader.cursor,687    };688  }689690  if (type === ENTRY_TYPE_CARD) {691    const cardholderIdx = reader.u16();692    const cardNumber = reader.lpString(1);693    const expMonth = reader.lpString(1);694    const expYear = reader.lpString(1);695    const cvc = reader.lpString(1);696    const nicknameIdx = reader.u16();697    const network = networkFromByte(reader.u8());698    const last4Idx = reader.u16();699    const tail: Partial<VaultEntry> = {};700    if (flags & F_HAS_TAGS) {701      const cnt = reader.u8();702      const tags: string[] = [];703      for (let i = 0; i < cnt; i++) {704        const v = lookupString(strings, reader.u16());705        if (v !== undefined) tags.push(v);706      }707      tail.tags = tags;708    }709    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());710711    return {712      entry: {713        ...(baseEntry as VaultEntry),714        type: "card",715        cardholderName: lookupString(strings, cardholderIdx),716        cardNumber,717        expMonth,718        expYear,719        cvc,720        cardNickname: lookupString(strings, nicknameIdx),721        network,722        last4: lookupString(strings, last4Idx),723        ...tail,724      } as VaultEntry,725      cursor: reader.cursor,726    };727  }728729  if (type === ENTRY_TYPE_PASSKEY) {730    const blobLen = reader.u32();731    const blob = reader.read(blobLen);732    const obj = decodeJsonSection<Record<string, unknown>>(blob);733    // MV-6: pick known passkey-bearing fields only — `...obj` let an734    // attacker-authored blob inject arbitrary keys past the section codec.735    // The full nested-shape validation happens in vault.ts736    // sanitizeRestoredEntry (every import path funnels through it); this737    // picker just stops unknown top-level keys at the decode boundary.738    const picked: Partial<VaultEntry> = {};739    const allow = [740      "passkey", "username", "email", "password", "url", "additionalUrls",741      "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",742      "canary", "deletedAt", "breach",743    ] as const;744    for (const k of allow) {745      if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];746    }747    return {748      entry: {749        ...(baseEntry as VaultEntry),750        type: "passkey",751        ...picked,752      } as VaultEntry,753      cursor: reader.cursor,754    };755  }756757  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);758}759760function entryTypeByte(entry: VaultEntry): number {761  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys762  // as type:"login" entries with a nested passkey sub-object, so the type763  // field alone is not sufficient to detect them.764  if (entry.passkey) return ENTRY_TYPE_PASSKEY;765  switch (entry.type) {766    case "note":767      return ENTRY_TYPE_NOTE;768    case "card":769      return ENTRY_TYPE_CARD;770    case "passkey":771      return ENTRY_TYPE_PASSKEY;772    default:773      return ENTRY_TYPE_LOGIN;774  }775}776777function networkToByte(network: CardNetwork | undefined): number {778  if (!network) return 0xff;779  const idx = NETWORK_VALUES.indexOf(network);780  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");781}782783function networkFromByte(b: number): CardNetwork | undefined {784  if (b === 0xff) return undefined;785  return NETWORK_VALUES[b] ?? "unknown";786}787788// ---------------------------------------------------------------------------789// UUID packing — 16 raw bytes vs 36-character hyphenated string790// ---------------------------------------------------------------------------791792export function uuidToBytes(uuid: string): Uint8Array {793  const hex = uuid.replace(/-/g, "");794  if (hex.length !== 32) throw new Error("UUID_FORMAT");795  const out = new Uint8Array(16);796  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);797  return out;798}799800export function bytesToUuid(bytes: Uint8Array): string {801  if (bytes.length !== 16) throw new Error("UUID_LENGTH");802  const hex = Array.from(bytes)803    .map((b) => b.toString(16).padStart(2, "0"))804    .join("");805  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;806}807808// ---------------------------------------------------------------------------809// Tiny binary reader/writer810// ---------------------------------------------------------------------------811812class BinaryWriter {813  private chunks: Uint8Array[] = [];814  private len = 0;815816  get length(): number {817    return this.len;818  }819820  u8(v: number): void {821    const b = new Uint8Array(1);822    b[0] = v & 0xff;823    this.push(b);824  }825826  setU8(offset: number, v: number): void {827    // Only used to back-patch the flags byte; always lands inside chunks[1] or828    // earlier. Reconstruct by walking chunks until we hit the offset.829    let cursor = 0;830    for (const c of this.chunks) {831      if (offset < cursor + c.length) {832        c[offset - cursor] = v & 0xff;833        return;834      }835      cursor += c.length;836    }837    throw new Error("setU8 out of range");838  }839840  u16(v: number): void {841    const b = new Uint8Array(2);842    new DataView(b.buffer).setUint16(0, v & 0xffff, true);843    this.push(b);844  }845846  u32(v: number): void {847    const b = new Uint8Array(4);848    new DataView(b.buffer).setUint32(0, v >>> 0, true);849    this.push(b);850  }851852  bytes(b: Uint8Array): void {853    this.push(b);854  }855856  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */857  lpString(value: string, lenSize: 1 | 2): void {858    const enc = new TextEncoder().encode(value);859    if (lenSize === 1) {860      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");861      this.u8(enc.length);862    } else {863      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");864      this.u16(enc.length);865    }866    this.push(enc);867  }868869  toBytes(): Uint8Array {870    const out = new Uint8Array(this.len);871    let cursor = 0;872    for (const c of this.chunks) {873      out.set(c, cursor);874      cursor += c.length;875    }876    return out;877  }878879  private push(b: Uint8Array): void {880    this.chunks.push(b);881    this.len += b.length;882  }883}884885// Exported for the PCH-02-008 bounds-check regression tests only — not part886// of the payload API surface.887export class BinaryReader {888  cursor: number;889  private view: DataView;890  constructor(private data: Uint8Array, start: number) {891    this.cursor = start;892    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);893  }894  // PCH-02-008: fail truncated imported payloads with the decoder's typed895  // PAYLOAD_TOO_SHORT error, not undefined/NaN reads or engine RangeErrors.896  private ensure(n: number): void {897    if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");898  }899  u8(): number {900    this.ensure(1);901    return this.data[this.cursor++];902  }903  u16(): number {904    this.ensure(2);905    const v = this.view.getUint16(this.cursor, true);906    this.cursor += 2;907    return v;908  }909  u32(): number {910    this.ensure(4);911    const v = this.view.getUint32(this.cursor, true);912    this.cursor += 4;913    return v;914  }915  read(n: number): Uint8Array {916    this.ensure(n);917    const out = this.data.subarray(this.cursor, this.cursor + n);918    this.cursor += n;919    return out;920  }921  lpString(lenSize: 1 | 2): string {922    const len = lenSize === 1 ? this.u8() : this.u16();923    return new TextDecoder().decode(this.read(len));924  }925}926927// ---------------------------------------------------------------------------928// Crypto helpers929// ---------------------------------------------------------------------------930931async function deriveKey(932  passphrase: string,933  salt: Uint8Array,934): Promise<Uint8Array> {935  const pw = passphrase.normalize("NFC");936  return argon2id({937    password: pw,938    salt,939    parallelism: ARGON2_PARALLELISM,940    iterations: ARGON2_ITERATIONS,941    memorySize: ARGON2_MEMORY_KIB,942    hashLength: 32,943    outputType: "binary",944  });945}946947async function aesGcmEncrypt(948  key: Uint8Array,949  nonce: Uint8Array,950  plaintext: Uint8Array951): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {952  const cryptoKey = await crypto.subtle.importKey(953    "raw",954    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,955    { name: "AES-GCM" },956    false,957    ["encrypt"]958  );959  const raw = await crypto.subtle.encrypt(960    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },961    cryptoKey,962    plaintext.buffer.slice(963      plaintext.byteOffset,964      plaintext.byteOffset + plaintext.byteLength965    ) as ArrayBuffer966  );967  const out = new Uint8Array(raw);968  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };969}970971async function aesGcmDecrypt(972  key: Uint8Array,973  nonce: Uint8Array,974  ciphertext: Uint8Array,975  tag: Uint8Array976): Promise<Uint8Array> {977  const cryptoKey = await crypto.subtle.importKey(978    "raw",979    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,980    { name: "AES-GCM" },981    false,982    ["decrypt"]983  );984  const combined = concat([ciphertext, tag]);985  const raw = await crypto.subtle.decrypt(986    {987      name: "AES-GCM",988      iv: nonce.buffer.slice(989        nonce.byteOffset,990        nonce.byteOffset + nonce.byteLength991      ) as ArrayBuffer,992      tagLength: 128,993    },994    cryptoKey,995    combined.buffer.slice(996      combined.byteOffset,997      combined.byteOffset + combined.byteLength998    ) as ArrayBuffer999  );1000  return new Uint8Array(raw);1001}10021003async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1004  const hex = await blake3(data);1005  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.1006  const out = new Uint8Array(CHECKSUM_LEN);1007  for (let i = 0; i < CHECKSUM_LEN; i++) {1008    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1009  }1010  return out;1011}10121013function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1014  if (a.length !== b.length) return false;1015  let diff = 0;1016  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1017  return diff === 0;1018}10191020function concat(parts: Uint8Array[]): Uint8Array {1021  let total = 0;1022  for (const p of parts) total += p.length;1023  const out = new Uint8Array(total);1024  let cursor = 0;1025  for (const p of parts) {1026    out.set(p, cursor);1027    cursor += p.length;1028  }1029  return out;1030}