peachPayload.ts

38.3 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// v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC82// and refuse gracefully; new clients detect v2 by this leading byte).83const FORMAT_V2 = 0x02;84// v3 format byte. Same envelope as v2 (length-prefixed inner + bucket85// padding) but login entries carry an extra `flags2` byte and an optional86// email field. Decoders fall back to v2 behavior for older payloads.87const FORMAT_V3 = 0x03;8889const ARGON2_MEMORY_KIB = 65536; // 64 MiB90const ARGON2_ITERATIONS = 3;91const ARGON2_PARALLELISM = 4;9293const SECTION_ENTRIES = 0x01;94const SECTION_SETTINGS = 0x02;95const SECTION_LICENSE = 0x03;96const SECTION_RECALL = 0x04;97const SECTION_DECLINED_CARDS = 0x05;9899const ENTRY_TYPE_LOGIN = 0x01;100const ENTRY_TYPE_NOTE = 0x02;101const ENTRY_TYPE_CARD = 0x03;102const ENTRY_TYPE_PASSKEY = 0x04;103104// Login entry flag bits105const F_HAS_TOTP = 1 << 0;106const F_HAS_TAGS = 1 << 1;107const F_HAS_NOTES = 1 << 2;108const F_HAS_CANARY = 1 << 3;109const F_HAS_PWCHANGED = 1 << 4;110const F_HAS_BREACH = 1 << 5;111const F_HAS_ADDITIONAL_URLS = 1 << 6;112const F_HAS_DELETED = 1 << 7;113114// v3-only flags2 byte (login entries only). Reserves room for further115// post-v3.6.82 fields without another envelope bump.116//117// CAUTION: flags2 bits are read positionally within FORMAT_V3. Adding a new118// boolean flag bit here does NOT require a format bump. But adding a new119// VARIABLE-LENGTH field gated by a new flag bit DOES — older v3 decoders don't120// know to skip the new field and the read cursor desyncs for everything after121// it. If you need a new variable-length field, bump FORMAT_V3 → FORMAT_V4 and122// gate the decoder on the format byte. Only add single-bit booleans to flags2.123const F2_HAS_EMAIL = 1 << 0;124125const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];126127// ---------------------------------------------------------------------------128// Public API129// ---------------------------------------------------------------------------130131export interface BuildPayloadInput {132  entries: VaultEntry[];133  settings: VaultSettings;134  license?: PeachLicenseExport;135  /** Optional declined card fingerprints (subset of settings — split out per spec). */136  declinedCardFingerprints?: string[];137  /** Optional recall map (subset of settings — split out per spec). */138  manualSummonDomains?: Record<string, ManualSummonRecord>;139  passphrase: string;140}141142export interface ParsePayloadOutput {143  entries: VaultEntry[];144  settings: VaultSettings;145  license?: PeachLicenseExport;146  declinedCardFingerprints?: string[];147  manualSummonDomains?: Record<string, ManualSummonRecord>;148}149150/**151 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →152 * AES-256-GCM encrypt under an Argon2id-derived key. Returns153 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into154 * `encodePeachScript` or the QR matrix encoder.155 */156export async function buildPayload(157  input: BuildPayloadInput,158  // PCH-01-001 test seam only: `raw:true` seals under the un-normalized159  // passphrase bytes to mint a pre-fix (legacy) envelope. Never used by160  // production callers — the build path always normalizes.161  opts: { raw?: boolean } = {},162): Promise<Uint8Array> {163  const sections: Uint8Array[] = [];164165  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));166  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));167  if (input.license) {168    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));169  }170  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {171    sections.push(172      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))173    );174  }175  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {176    sections.push(177      await buildSection(178        SECTION_DECLINED_CARDS,179        encodeJsonSection(input.declinedCardFingerprints)180      )181    );182  }183184  const header = new Uint8Array(HEADER_LEN);185  const headerView = new DataView(header.buffer);186  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump187  header[4] = VERSION;188  header[5] = sections.length;189  // bytes 6,7: reserved (zero)190191  const body = concat([header, ...sections]);192  const checksum = await blake3Truncated(body);193  const inner = concat([body, checksum]);194195  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]196  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and197  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent198  // wrong-password failure. Same envelope as v2; v3 adds optional email +199  // flags2 byte on login entries (see encodeEntry/decodeEntry).200  const header5 = new Uint8Array(5);201  header5[0] = FORMAT_V3;202  new DataView(header5.buffer).setUint32(1, inner.length, true);203  const wrapped = padToNearestBucket(concat([header5, inner]));204205  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));206  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));207  const key = await deriveKey(input.passphrase, salt, opts);208  try {209    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);210    return concat([salt, nonce, ciphertext, tag]);211  } finally {212    key.fill(0);213  }214}215216/**217 * Parse the encrypted envelope back to a typed payload. Throws218 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a219 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or220 * `CHECKSUM_FAILED` on a corrupt inner payload.221 */222export async function parsePayload(223  envelope: Uint8Array,224  passphrase: string225): Promise<ParsePayloadOutput> {226  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {227    throw new Error("PAYLOAD_TOO_SHORT");228  }229230  const salt = envelope.subarray(0, SALT_LEN);231  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);232  const tag = envelope.subarray(envelope.length - TAG_LEN);233  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);234235  let decrypted: Uint8Array;236  const key = await deriveKey(passphrase, salt);237  try {238    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);239  } catch {240    // PCH-01-001: an export sealed before this fix under a non-NFC passphrase241    // derives a different key. Retry once with the raw bytes — mirrors242    // backup.ts. Only attempt the fallback when normalization actually changed243    // the string (an all-ASCII passphrase has nothing to fall back to).244    if (passphrase === passphrase.normalize("NFC")) {245      throw new Error("INVALID_PASSPHRASE");246    }247    const rawKey = await deriveKey(passphrase, salt, { raw: true });248    try {249      decrypted = await aesGcmDecrypt(rawKey, nonce, ciphertext, tag);250    } catch {251      throw new Error("INVALID_PASSPHRASE");252    } finally {253      rawKey.fill(0);254    }255  } finally {256    key.fill(0);257  }258259  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by260  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.261  let inner: Uint8Array;262  let formatVersion: number = 1;263  if (264    decrypted.length >= 5 &&265    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)266  ) {267    formatVersion = decrypted[0];268    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);269    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");270    inner = decrypted.subarray(5, 5 + innerLen);271  } else {272    inner = decrypted; // v1 format — no length prefix, no padding273  }274275  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");276277  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);278  const stored = inner.subarray(inner.length - CHECKSUM_LEN);279  const computed = await blake3Truncated(body);280  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");281282  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);283  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");284  const version = body[4];285  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);286  const sectionCount = body[5];287288  let cursor = HEADER_LEN;289  const out: ParsePayloadOutput = {290    entries: [],291    settings: {},292  };293  let totalDecompressed = 0;294  for (let i = 0; i < sectionCount; i++) {295    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");296    const type = body[cursor];297    const length = view.getUint32(cursor + 1, true);298    const clength = view.getUint32(cursor + 5, true);299    // MV-4: `length` is attacker-declared. Refuse before decompressing —300    // never let a 100-byte section promise (and zstd deliver) gigabytes.301    if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {302      throw importTooLarge(303        `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,304      );305    }306    // MV-4: cap the total decompressed budget across all sections too, so307    // many maximal sections can't multiply past the single-section cap.308    totalDecompressed += length;309    if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {310      throw importTooLarge("total decompressed payload exceeds limit");311    }312    const dataStart = cursor + 9;313    const dataEnd = dataStart + clength;314    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");315    const compressed = body.subarray(dataStart, dataEnd);316    const data = await decompressZstd(compressed);317    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");318319    switch (type) {320      case SECTION_ENTRIES:321        out.entries = decodeEntriesSection(data, formatVersion);322        break;323      case SECTION_SETTINGS:324        out.settings = decodeJsonSection<VaultSettings>(data);325        break;326      case SECTION_LICENSE:327        out.license = decodeJsonSection<PeachLicenseExport>(data);328        break;329      case SECTION_RECALL:330        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);331        break;332      case SECTION_DECLINED_CARDS:333        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);334        break;335      default:336        // Unknown section — skip rather than fail, so future-introduced337        // sections do not break older clients reading newer payloads.338        break;339    }340341    cursor = dataEnd;342  }343344  return out;345}346347// ---------------------------------------------------------------------------348// Section assembly349// ---------------------------------------------------------------------------350351async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {352  const compressed = await compressZstd(raw, 19);353  const out = new Uint8Array(9 + compressed.length);354  const view = new DataView(out.buffer);355  out[0] = type;356  view.setUint32(1, raw.length, true);357  view.setUint32(5, compressed.length, true);358  out.set(compressed, 9);359  return out;360}361362function encodeJsonSection(value: unknown): Uint8Array {363  return new TextEncoder().encode(JSON.stringify(value));364}365366function decodeJsonSection<T>(data: Uint8Array): T {367  return JSON.parse(new TextDecoder().decode(data)) as T;368}369370// ---------------------------------------------------------------------------371// Entries section: shared string table + per-type packed bodies372// ---------------------------------------------------------------------------373374class StringTableBuilder {375  private map = new Map<string, number>();376  private list: string[] = [];377378  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */379  intern(value: string | undefined): number {380    if (!value) return 0;381    const existing = this.map.get(value);382    if (existing !== undefined) return existing;383    const idx = this.list.length + 1; // 1-indexed; 0 = absent384    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");385    this.map.set(value, idx);386    this.list.push(value);387    return idx;388  }389390  serialize(): Uint8Array {391    const enc = new TextEncoder();392    const encoded = this.list.map((s) => enc.encode(s));393    let total = 2; // u16 count394    for (const e of encoded) total += 2 + e.length;395    const out = new Uint8Array(total);396    const view = new DataView(out.buffer);397    let cursor = 0;398    view.setUint16(cursor, encoded.length, true);399    cursor += 2;400    for (const e of encoded) {401      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");402      view.setUint16(cursor, e.length, true);403      cursor += 2;404      out.set(e, cursor);405      cursor += e.length;406    }407    return out;408  }409}410411function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {412  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);413  const count = view.getUint16(cursor, true);414  cursor += 2;415  const strings: string[] = [];416  const dec = new TextDecoder();417  for (let i = 0; i < count; i++) {418    const len = view.getUint16(cursor, true);419    cursor += 2;420    strings.push(dec.decode(data.subarray(cursor, cursor + len)));421    cursor += len;422  }423  return { strings, cursor };424}425426function lookupString(strings: string[], idx: number): string | undefined {427  if (idx === 0) return undefined;428  return strings[idx - 1];429}430431function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {432  const table = new StringTableBuilder();433  const bodies: Uint8Array[] = [];434435  // Pre-intern title for every entry so the table is built consistently.436  for (const entry of entries) table.intern(entry.title);437438  for (const entry of entries) {439    bodies.push(encodeEntry(entry, table, formatVersion));440  }441442  const tableBytes = table.serialize();443444  // [u32 count][string_table][entries...]445  let bodiesLen = 0;446  for (const b of bodies) bodiesLen += b.length;447  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);448  const view = new DataView(out.buffer);449  view.setUint32(0, entries.length, true);450  out.set(tableBytes, 4);451  let cursor = 4 + tableBytes.length;452  for (const b of bodies) {453    out.set(b, cursor);454    cursor += b.length;455  }456  return out;457}458459function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {460  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);461  const count = view.getUint32(0, true);462  const { strings, cursor: afterTable } = readStringTable(data, 4);463464  const out: VaultEntry[] = [];465  let cursor = afterTable;466  for (let i = 0; i < count; i++) {467    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);468    out.push(entry);469    cursor = next;470  }471  return out;472}473474// ---------------------------------------------------------------------------475// Per-entry packing476// ---------------------------------------------------------------------------477478function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {479  const writer = new BinaryWriter();480  const type = entryTypeByte(entry);481  writer.u8(type);482  writer.bytes(uuidToBytes(entry.id));483484  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx485  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);486  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);487  const updatedDelta = Math.max(0, updatedSec - createdSec);488  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;489490  // flags placeholder (we'll overwrite at the end)491  const flagOffset = writer.length;492  writer.u8(0);493  // v3-only flags2 placeholder, written for every entry so the section494  // layout stays uniform across types.495  let flags2Offset = -1;496  if (formatVersion === FORMAT_V3) {497    flags2Offset = writer.length;498    writer.u8(0);499  }500501  writer.u32(createdSec);502  writer.u32(updatedDelta);503  writer.u32(deletedSec);504  writer.u16(table.intern(entry.title));505506  let flags = 0;507  let flags2 = 0;508  if (entry.deletedAt) flags |= F_HAS_DELETED;509510  if (type === ENTRY_TYPE_LOGIN) {511    writer.u16(table.intern(entry.url));512    writer.u16(table.intern(entry.username));513    writer.lpString(entry.password ?? "", 1);514    if (formatVersion === FORMAT_V3 && entry.email) {515      flags2 |= F2_HAS_EMAIL;516      writer.lpString(entry.email, 1);517    }518519    if (entry.totp) {520      flags |= F_HAS_TOTP;521      writer.lpString(entry.totp, 1);522    }523    if (entry.tags && entry.tags.length > 0) {524      flags |= F_HAS_TAGS;525      writer.u8(Math.min(entry.tags.length, 0xff));526      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {527        writer.u16(table.intern(entry.tags[i]));528      }529    }530    if (entry.notes) {531      flags |= F_HAS_NOTES;532      writer.u16(table.intern(entry.notes));533    }534    if (entry.canary) {535      flags |= F_HAS_CANARY;536      writer.u16(table.intern(entry.canary.webhookUrl));537      writer.u16(table.intern(entry.canary.label));538    }539    if (entry.passwordChangedAt) {540      flags |= F_HAS_PWCHANGED;541      const pwSec = Math.floor(entry.passwordChangedAt / 1000);542      writer.u32(Math.max(0, pwSec - createdSec));543    }544    if (typeof entry.breach === "number" && entry.breach > 0) {545      flags |= F_HAS_BREACH;546      writer.u32(entry.breach);547    }548    if (entry.additionalUrls && entry.additionalUrls.length > 0) {549      flags |= F_HAS_ADDITIONAL_URLS;550      writer.u8(Math.min(entry.additionalUrls.length, 0xff));551      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {552        writer.u16(table.intern(entry.additionalUrls[i]));553      }554    }555  } else if (type === ENTRY_TYPE_NOTE) {556    if (entry.notes) {557      flags |= F_HAS_NOTES;558      writer.u16(table.intern(entry.notes));559    }560    if (entry.tags && entry.tags.length > 0) {561      flags |= F_HAS_TAGS;562      writer.u8(Math.min(entry.tags.length, 0xff));563      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {564        writer.u16(table.intern(entry.tags[i]));565      }566    }567  } else if (type === ENTRY_TYPE_CARD) {568    writer.u16(table.intern(entry.cardholderName));569    writer.lpString(entry.cardNumber ?? "", 1);570    writer.lpString(entry.expMonth ?? "", 1);571    writer.lpString(entry.expYear ?? "", 1);572    writer.lpString(entry.cvc ?? "", 1);573    writer.u16(table.intern(entry.cardNickname));574    writer.u8(networkToByte(entry.network));575    writer.u16(table.intern(entry.last4));576    if (entry.tags && entry.tags.length > 0) {577      flags |= F_HAS_TAGS;578      writer.u8(Math.min(entry.tags.length, 0xff));579      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {580        writer.u16(table.intern(entry.tags[i]));581      }582    }583    if (entry.notes) {584      flags |= F_HAS_NOTES;585      writer.u16(table.intern(entry.notes));586    }587  } else if (type === ENTRY_TYPE_PASSKEY) {588    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.589    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.590    const pk = entry.passkey;591    const flat = entry as unknown as Record<string, unknown>;592    const blob = encodeJsonSection({593      rpId:             pk ? pk.rpId             : flat["rpId"],594      rpName:           pk ? pk.rpName           : flat["rpName"],595      credentialId:     pk ? pk.credentialId     : flat["credentialId"],596      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],597      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],598      userHandle:       pk ? pk.userHandle       : flat["userHandle"],599      userName:         pk ? pk.userName         : flat["userName"],600      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],601      algorithm:        pk ? pk.algorithm        : flat["algorithm"],602      signCount:        pk ? pk.signCount        : flat["signCount"],603      aaguid:           pk ? pk.aaguid           : flat["aaguid"],604      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],605      backupState:      pk ? pk.backupState      : flat["backupState"],606      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],607      // Preserve the FULL login field set for passkey-bearing login entries.608      // A v2.11+ passkey hangs off a normal login (type:"login" + passkey609      // sub-object), so it can carry a password, TOTP seed, notes, tags, email,610      // canary, additional URLs, and the password-age stamp — all of which were611      // silently dropped on export before this (SEV-1-B). The blob is JSON, so612      // adding keys is backward-compatible: old decoders ignore unknown keys613      // and old exports simply lack them. Only emit these when `pk` is set;614      // pre-v2.11 flat passkey entries had no login fields to preserve.615      ...(pk ? {616        ...(entry.password          !== undefined ? { password:          entry.password } : {}),617        ...(entry.url               !== undefined ? { url:               entry.url } : {}),618        ...(entry.username          !== undefined ? { username:          entry.username } : {}),619        ...(entry.email             !== undefined ? { email:             entry.email } : {}),620        ...(entry.totp              !== undefined ? { totp:              entry.totp } : {}),621        ...(entry.notes             !== undefined ? { notes:             entry.notes } : {}),622        ...(entry.tags              !== undefined ? { tags:              entry.tags } : {}),623        ...(entry.canary            !== undefined ? { canary:            entry.canary } : {}),624        ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),625        ...(entry.breach            !== undefined ? { breach:            entry.breach } : {}),626        ...(entry.additionalUrls    !== undefined ? { additionalUrls:    entry.additionalUrls } : {}),627      } : {}),628    });629    writer.u32(blob.length);630    writer.bytes(blob);631  }632633  writer.setU8(flagOffset, flags);634  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);635  return writer.toBytes();636}637638function decodeEntry(639  data: Uint8Array,640  start: number,641  strings: string[],642  formatVersion: number643): { entry: VaultEntry; cursor: number } {644  const reader = new BinaryReader(data, start);645  const type = reader.u8();646  const id = bytesToUuid(reader.read(16));647  const flags = reader.u8();648  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;649  const createdSec = reader.u32();650  const updatedDelta = reader.u32();651  const deletedSec = reader.u32();652  const titleIdx = reader.u16();653654  const createdAt = createdSec * 1000;655  const modifiedAt = (createdSec + updatedDelta) * 1000;656  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;657  const title = lookupString(strings, titleIdx) ?? "";658659  const baseEntry: Partial<VaultEntry> = {660    id,661    title,662    createdAt,663    modifiedAt,664    ...(deletedAt !== undefined ? { deletedAt } : {}),665  };666667  if (type === ENTRY_TYPE_LOGIN) {668    const urlIdx = reader.u16();669    const usernameIdx = reader.u16();670    const password = reader.lpString(1);671    const tail: Partial<VaultEntry> = {};672673    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);674675    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);676    if (flags & F_HAS_TAGS) {677      const cnt = reader.u8();678      const tags: string[] = [];679      for (let i = 0; i < cnt; i++) {680        const v = lookupString(strings, reader.u16());681        if (v !== undefined) tags.push(v);682      }683      tail.tags = tags;684    }685    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());686    if (flags & F_HAS_CANARY) {687      const webhookUrl = lookupString(strings, reader.u16()) ?? "";688      const label = lookupString(strings, reader.u16());689      const canary: Canary = { webhookUrl };690      if (label !== undefined) canary.label = label;691      tail.canary = canary;692    }693    if (flags & F_HAS_PWCHANGED) {694      const pwDelta = reader.u32();695      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;696    }697    if (flags & F_HAS_BREACH) tail.breach = reader.u32();698    if (flags & F_HAS_ADDITIONAL_URLS) {699      const cnt = reader.u8();700      const urls: string[] = [];701      for (let i = 0; i < cnt; i++) {702        const v = lookupString(strings, reader.u16());703        if (v !== undefined) urls.push(v);704      }705      tail.additionalUrls = urls;706    }707708    return {709      entry: {710        ...(baseEntry as VaultEntry),711        type: "login",712        url: lookupString(strings, urlIdx),713        username: lookupString(strings, usernameIdx),714        password,715        ...tail,716      } as VaultEntry,717      cursor: reader.cursor,718    };719  }720721  if (type === ENTRY_TYPE_NOTE) {722    const tail: Partial<VaultEntry> = {};723    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());724    if (flags & F_HAS_TAGS) {725      const cnt = reader.u8();726      const tags: string[] = [];727      for (let i = 0; i < cnt; i++) {728        const v = lookupString(strings, reader.u16());729        if (v !== undefined) tags.push(v);730      }731      tail.tags = tags;732    }733    return {734      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,735      cursor: reader.cursor,736    };737  }738739  if (type === ENTRY_TYPE_CARD) {740    const cardholderIdx = reader.u16();741    const cardNumber = reader.lpString(1);742    const expMonth = reader.lpString(1);743    const expYear = reader.lpString(1);744    const cvc = reader.lpString(1);745    const nicknameIdx = reader.u16();746    const network = networkFromByte(reader.u8());747    const last4Idx = reader.u16();748    const tail: Partial<VaultEntry> = {};749    if (flags & F_HAS_TAGS) {750      const cnt = reader.u8();751      const tags: string[] = [];752      for (let i = 0; i < cnt; i++) {753        const v = lookupString(strings, reader.u16());754        if (v !== undefined) tags.push(v);755      }756      tail.tags = tags;757    }758    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());759760    return {761      entry: {762        ...(baseEntry as VaultEntry),763        type: "card",764        cardholderName: lookupString(strings, cardholderIdx),765        cardNumber,766        expMonth,767        expYear,768        cvc,769        cardNickname: lookupString(strings, nicknameIdx),770        network,771        last4: lookupString(strings, last4Idx),772        ...tail,773      } as VaultEntry,774      cursor: reader.cursor,775    };776  }777778  if (type === ENTRY_TYPE_PASSKEY) {779    const blobLen = reader.u32();780    const blob = reader.read(blobLen);781    const obj = decodeJsonSection<Record<string, unknown>>(blob);782    // MV-6: pick known passkey-bearing fields only — `...obj` let an783    // attacker-authored blob inject arbitrary keys past the section codec.784    // The full nested-shape validation happens in vault.ts785    // sanitizeRestoredEntry (every import path funnels through it); this786    // picker just stops unknown top-level keys at the decode boundary.787    const picked: Partial<VaultEntry> = {};788    const allow = [789      "passkey", "username", "email", "password", "url", "additionalUrls",790      "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",791      "canary", "deletedAt", "breach",792      // legacy flat passkey fields (pre-v2.11.0) — consumed by793      // migrateLegacyPasskey, then dropped on next save794      "rpId", "rpName", "credentialId", "publicKeyCose", "privateKeyJwk",795      "userHandle", "userName", "userDisplayName", "algorithm", "signCount",796      "aaguid", "backupEligible", "backupState", "lastUsedAt", "browser",797      "transports",798    ] as const;799    for (const k of allow) {800      if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];801    }802    return {803      entry: {804        ...(baseEntry as VaultEntry),805        type: "passkey",806        ...picked,807      } as VaultEntry,808      cursor: reader.cursor,809    };810  }811812  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);813}814815function entryTypeByte(entry: VaultEntry): number {816  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys817  // as type:"login" entries with a nested passkey sub-object, so the type818  // field alone is not sufficient to detect them.819  if (entry.passkey) return ENTRY_TYPE_PASSKEY;820  switch (entry.type) {821    case "note":822      return ENTRY_TYPE_NOTE;823    case "card":824      return ENTRY_TYPE_CARD;825    case "passkey":826      return ENTRY_TYPE_PASSKEY;827    default:828      return ENTRY_TYPE_LOGIN;829  }830}831832function networkToByte(network: CardNetwork | undefined): number {833  if (!network) return 0xff;834  const idx = NETWORK_VALUES.indexOf(network);835  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");836}837838function networkFromByte(b: number): CardNetwork | undefined {839  if (b === 0xff) return undefined;840  return NETWORK_VALUES[b] ?? "unknown";841}842843// ---------------------------------------------------------------------------844// UUID packing — 16 raw bytes vs 36-character hyphenated string845// ---------------------------------------------------------------------------846847export function uuidToBytes(uuid: string): Uint8Array {848  const hex = uuid.replace(/-/g, "");849  if (hex.length !== 32) throw new Error("UUID_FORMAT");850  const out = new Uint8Array(16);851  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);852  return out;853}854855export function bytesToUuid(bytes: Uint8Array): string {856  if (bytes.length !== 16) throw new Error("UUID_LENGTH");857  const hex = Array.from(bytes)858    .map((b) => b.toString(16).padStart(2, "0"))859    .join("");860  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;861}862863// ---------------------------------------------------------------------------864// Tiny binary reader/writer865// ---------------------------------------------------------------------------866867class BinaryWriter {868  private chunks: Uint8Array[] = [];869  private len = 0;870871  get length(): number {872    return this.len;873  }874875  u8(v: number): void {876    const b = new Uint8Array(1);877    b[0] = v & 0xff;878    this.push(b);879  }880881  setU8(offset: number, v: number): void {882    // Only used to back-patch the flags byte; always lands inside chunks[1] or883    // earlier. Reconstruct by walking chunks until we hit the offset.884    let cursor = 0;885    for (const c of this.chunks) {886      if (offset < cursor + c.length) {887        c[offset - cursor] = v & 0xff;888        return;889      }890      cursor += c.length;891    }892    throw new Error("setU8 out of range");893  }894895  u16(v: number): void {896    const b = new Uint8Array(2);897    new DataView(b.buffer).setUint16(0, v & 0xffff, true);898    this.push(b);899  }900901  u32(v: number): void {902    const b = new Uint8Array(4);903    new DataView(b.buffer).setUint32(0, v >>> 0, true);904    this.push(b);905  }906907  bytes(b: Uint8Array): void {908    this.push(b);909  }910911  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */912  lpString(value: string, lenSize: 1 | 2): void {913    const enc = new TextEncoder().encode(value);914    if (lenSize === 1) {915      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");916      this.u8(enc.length);917    } else {918      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");919      this.u16(enc.length);920    }921    this.push(enc);922  }923924  toBytes(): Uint8Array {925    const out = new Uint8Array(this.len);926    let cursor = 0;927    for (const c of this.chunks) {928      out.set(c, cursor);929      cursor += c.length;930    }931    return out;932  }933934  private push(b: Uint8Array): void {935    this.chunks.push(b);936    this.len += b.length;937  }938}939940// Exported for the PCH-02-008 bounds-check regression tests only — not part941// of the payload API surface.942export class BinaryReader {943  cursor: number;944  private view: DataView;945  constructor(private data: Uint8Array, start: number) {946    this.cursor = start;947    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);948  }949  // PCH-02-008: fail truncated imported payloads with the decoder's typed950  // PAYLOAD_TOO_SHORT error, not undefined/NaN reads or engine RangeErrors.951  private ensure(n: number): void {952    if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");953  }954  u8(): number {955    this.ensure(1);956    return this.data[this.cursor++];957  }958  u16(): number {959    this.ensure(2);960    const v = this.view.getUint16(this.cursor, true);961    this.cursor += 2;962    return v;963  }964  u32(): number {965    this.ensure(4);966    const v = this.view.getUint32(this.cursor, true);967    this.cursor += 4;968    return v;969  }970  read(n: number): Uint8Array {971    this.ensure(n);972    const out = this.data.subarray(this.cursor, this.cursor + n);973    this.cursor += n;974    return out;975  }976  lpString(lenSize: 1 | 2): string {977    const len = lenSize === 1 ? this.u8() : this.u16();978    return new TextDecoder().decode(this.read(len));979  }980}981982// ---------------------------------------------------------------------------983// Crypto helpers984// ---------------------------------------------------------------------------985986async function deriveKey(987  passphrase: string,988  salt: Uint8Array,989  opts: { raw?: boolean } = {},990): Promise<Uint8Array> {991  // PCH-01-001 / LV-1 parity: NFC-normalize the export passphrase to match the992  // master-password KDF chokepoint (argon2.ts). A composed-vs-decomposed mismatch993  // (macOS NFD dead-keys vs. Android NFC) otherwise derives a different key and994  // makes a valid backup unrestorable. `raw:true` is used ONLY by the parse995  // fallback to open pre-fix exports sealed under un-normalized bytes.996  const pw = opts.raw ? passphrase : passphrase.normalize("NFC");997  return argon2id({998    password: pw,999    salt,1000    parallelism: ARGON2_PARALLELISM,1001    iterations: ARGON2_ITERATIONS,1002    memorySize: ARGON2_MEMORY_KIB,1003    hashLength: 32,1004    outputType: "binary",1005  });1006}10071008async function aesGcmEncrypt(1009  key: Uint8Array,1010  nonce: Uint8Array,1011  plaintext: Uint8Array1012): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {1013  const cryptoKey = await crypto.subtle.importKey(1014    "raw",1015    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,1016    { name: "AES-GCM" },1017    false,1018    ["encrypt"]1019  );1020  const raw = await crypto.subtle.encrypt(1021    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },1022    cryptoKey,1023    plaintext.buffer.slice(1024      plaintext.byteOffset,1025      plaintext.byteOffset + plaintext.byteLength1026    ) as ArrayBuffer1027  );1028  const out = new Uint8Array(raw);1029  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };1030}10311032async function aesGcmDecrypt(1033  key: Uint8Array,1034  nonce: Uint8Array,1035  ciphertext: Uint8Array,1036  tag: Uint8Array1037): Promise<Uint8Array> {1038  const cryptoKey = await crypto.subtle.importKey(1039    "raw",1040    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,1041    { name: "AES-GCM" },1042    false,1043    ["decrypt"]1044  );1045  const combined = concat([ciphertext, tag]);1046  const raw = await crypto.subtle.decrypt(1047    {1048      name: "AES-GCM",1049      iv: nonce.buffer.slice(1050        nonce.byteOffset,1051        nonce.byteOffset + nonce.byteLength1052      ) as ArrayBuffer,1053      tagLength: 128,1054    },1055    cryptoKey,1056    combined.buffer.slice(1057      combined.byteOffset,1058      combined.byteOffset + combined.byteLength1059    ) as ArrayBuffer1060  );1061  return new Uint8Array(raw);1062}10631064async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1065  const hex = await blake3(data);1066  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.1067  const out = new Uint8Array(CHECKSUM_LEN);1068  for (let i = 0; i < CHECKSUM_LEN; i++) {1069    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1070  }1071  return out;1072}10731074function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1075  if (a.length !== b.length) return false;1076  let diff = 0;1077  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1078  return diff === 0;1079}10801081function concat(parts: Uint8Array[]): Uint8Array {1082  let total = 0;1083  for (const p of parts) total += p.length;1084  const out = new Uint8Array(total);1085  let cursor = 0;1086  for (const p of parts) {1087    out.set(p, cursor);1088    cursor += p.length;1089  }1090  return out;1091}