peachPayload.ts

35.4 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(input: BuildPayloadInput): Promise<Uint8Array> {157  const sections: Uint8Array[] = [];158159  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));160  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));161  if (input.license) {162    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));163  }164  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {165    sections.push(166      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))167    );168  }169  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {170    sections.push(171      await buildSection(172        SECTION_DECLINED_CARDS,173        encodeJsonSection(input.declinedCardFingerprints)174      )175    );176  }177178  const header = new Uint8Array(HEADER_LEN);179  const headerView = new DataView(header.buffer);180  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump181  header[4] = VERSION;182  header[5] = sections.length;183  // bytes 6,7: reserved (zero)184185  const body = concat([header, ...sections]);186  const checksum = await blake3Truncated(body);187  const inner = concat([body, checksum]);188189  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]190  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and191  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent192  // wrong-password failure. Same envelope as v2; v3 adds optional email +193  // flags2 byte on login entries (see encodeEntry/decodeEntry).194  const header5 = new Uint8Array(5);195  header5[0] = FORMAT_V3;196  new DataView(header5.buffer).setUint32(1, inner.length, true);197  const wrapped = padToNearestBucket(concat([header5, inner]));198199  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));200  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));201  const key = await deriveKey(input.passphrase, salt);202  try {203    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);204    return concat([salt, nonce, ciphertext, tag]);205  } finally {206    key.fill(0);207  }208}209210/**211 * Parse the encrypted envelope back to a typed payload. Throws212 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a213 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or214 * `CHECKSUM_FAILED` on a corrupt inner payload.215 */216export async function parsePayload(217  envelope: Uint8Array,218  passphrase: string219): Promise<ParsePayloadOutput> {220  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {221    throw new Error("PAYLOAD_TOO_SHORT");222  }223224  const salt = envelope.subarray(0, SALT_LEN);225  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);226  const tag = envelope.subarray(envelope.length - TAG_LEN);227  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);228229  const key = await deriveKey(passphrase, salt);230  let decrypted: Uint8Array;231  try {232    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);233  } catch {234    throw new Error("INVALID_PASSPHRASE");235  } finally {236    key.fill(0);237  }238239  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by240  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.241  let inner: Uint8Array;242  let formatVersion: number = 1;243  if (244    decrypted.length >= 5 &&245    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)246  ) {247    formatVersion = decrypted[0];248    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);249    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");250    inner = decrypted.subarray(5, 5 + innerLen);251  } else {252    inner = decrypted; // v1 format — no length prefix, no padding253  }254255  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");256257  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);258  const stored = inner.subarray(inner.length - CHECKSUM_LEN);259  const computed = await blake3Truncated(body);260  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");261262  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);263  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");264  const version = body[4];265  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);266  const sectionCount = body[5];267268  let cursor = HEADER_LEN;269  const out: ParsePayloadOutput = {270    entries: [],271    settings: {},272  };273  let totalDecompressed = 0;274  for (let i = 0; i < sectionCount; i++) {275    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");276    const type = body[cursor];277    const length = view.getUint32(cursor + 1, true);278    const clength = view.getUint32(cursor + 5, true);279    // MV-4: `length` is attacker-declared. Refuse before decompressing —280    // never let a 100-byte section promise (and zstd deliver) gigabytes.281    if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {282      throw importTooLarge(283        `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,284      );285    }286    // MV-4: cap the total decompressed budget across all sections too, so287    // many maximal sections can't multiply past the single-section cap.288    totalDecompressed += length;289    if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {290      throw importTooLarge("total decompressed payload exceeds limit");291    }292    const dataStart = cursor + 9;293    const dataEnd = dataStart + clength;294    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");295    const compressed = body.subarray(dataStart, dataEnd);296    const data = await decompressZstd(compressed);297    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");298299    switch (type) {300      case SECTION_ENTRIES:301        out.entries = decodeEntriesSection(data, formatVersion);302        break;303      case SECTION_SETTINGS:304        out.settings = decodeJsonSection<VaultSettings>(data);305        break;306      case SECTION_LICENSE:307        out.license = decodeJsonSection<PeachLicenseExport>(data);308        break;309      case SECTION_RECALL:310        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);311        break;312      case SECTION_DECLINED_CARDS:313        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);314        break;315      default:316        // Unknown section — skip rather than fail, so future-introduced317        // sections do not break older clients reading newer payloads.318        break;319    }320321    cursor = dataEnd;322  }323324  return out;325}326327// ---------------------------------------------------------------------------328// Section assembly329// ---------------------------------------------------------------------------330331async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {332  const compressed = await compressZstd(raw, 19);333  const out = new Uint8Array(9 + compressed.length);334  const view = new DataView(out.buffer);335  out[0] = type;336  view.setUint32(1, raw.length, true);337  view.setUint32(5, compressed.length, true);338  out.set(compressed, 9);339  return out;340}341342function encodeJsonSection(value: unknown): Uint8Array {343  return new TextEncoder().encode(JSON.stringify(value));344}345346function decodeJsonSection<T>(data: Uint8Array): T {347  return JSON.parse(new TextDecoder().decode(data)) as T;348}349350// ---------------------------------------------------------------------------351// Entries section: shared string table + per-type packed bodies352// ---------------------------------------------------------------------------353354class StringTableBuilder {355  private map = new Map<string, number>();356  private list: string[] = [];357358  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */359  intern(value: string | undefined): number {360    if (!value) return 0;361    const existing = this.map.get(value);362    if (existing !== undefined) return existing;363    const idx = this.list.length + 1; // 1-indexed; 0 = absent364    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");365    this.map.set(value, idx);366    this.list.push(value);367    return idx;368  }369370  serialize(): Uint8Array {371    const enc = new TextEncoder();372    const encoded = this.list.map((s) => enc.encode(s));373    let total = 2; // u16 count374    for (const e of encoded) total += 2 + e.length;375    const out = new Uint8Array(total);376    const view = new DataView(out.buffer);377    let cursor = 0;378    view.setUint16(cursor, encoded.length, true);379    cursor += 2;380    for (const e of encoded) {381      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");382      view.setUint16(cursor, e.length, true);383      cursor += 2;384      out.set(e, cursor);385      cursor += e.length;386    }387    return out;388  }389}390391function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {392  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);393  const count = view.getUint16(cursor, true);394  cursor += 2;395  const strings: string[] = [];396  const dec = new TextDecoder();397  for (let i = 0; i < count; i++) {398    const len = view.getUint16(cursor, true);399    cursor += 2;400    strings.push(dec.decode(data.subarray(cursor, cursor + len)));401    cursor += len;402  }403  return { strings, cursor };404}405406function lookupString(strings: string[], idx: number): string | undefined {407  if (idx === 0) return undefined;408  return strings[idx - 1];409}410411function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {412  const table = new StringTableBuilder();413  const bodies: Uint8Array[] = [];414415  // Pre-intern title for every entry so the table is built consistently.416  for (const entry of entries) table.intern(entry.title);417418  for (const entry of entries) {419    bodies.push(encodeEntry(entry, table, formatVersion));420  }421422  const tableBytes = table.serialize();423424  // [u32 count][string_table][entries...]425  let bodiesLen = 0;426  for (const b of bodies) bodiesLen += b.length;427  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);428  const view = new DataView(out.buffer);429  view.setUint32(0, entries.length, true);430  out.set(tableBytes, 4);431  let cursor = 4 + tableBytes.length;432  for (const b of bodies) {433    out.set(b, cursor);434    cursor += b.length;435  }436  return out;437}438439function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {440  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);441  const count = view.getUint32(0, true);442  const { strings, cursor: afterTable } = readStringTable(data, 4);443444  const out: VaultEntry[] = [];445  let cursor = afterTable;446  for (let i = 0; i < count; i++) {447    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);448    out.push(entry);449    cursor = next;450  }451  return out;452}453454// ---------------------------------------------------------------------------455// Per-entry packing456// ---------------------------------------------------------------------------457458function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {459  const writer = new BinaryWriter();460  const type = entryTypeByte(entry);461  writer.u8(type);462  writer.bytes(uuidToBytes(entry.id));463464  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx465  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);466  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);467  const updatedDelta = Math.max(0, updatedSec - createdSec);468  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;469470  // flags placeholder (we'll overwrite at the end)471  const flagOffset = writer.length;472  writer.u8(0);473  // v3-only flags2 placeholder, written for every entry so the section474  // layout stays uniform across types.475  let flags2Offset = -1;476  if (formatVersion === FORMAT_V3) {477    flags2Offset = writer.length;478    writer.u8(0);479  }480481  writer.u32(createdSec);482  writer.u32(updatedDelta);483  writer.u32(deletedSec);484  writer.u16(table.intern(entry.title));485486  let flags = 0;487  let flags2 = 0;488  if (entry.deletedAt) flags |= F_HAS_DELETED;489490  if (type === ENTRY_TYPE_LOGIN) {491    writer.u16(table.intern(entry.url));492    writer.u16(table.intern(entry.username));493    writer.lpString(entry.password ?? "", 1);494    if (formatVersion === FORMAT_V3 && entry.email) {495      flags2 |= F2_HAS_EMAIL;496      writer.lpString(entry.email, 1);497    }498499    if (entry.totp) {500      flags |= F_HAS_TOTP;501      writer.lpString(entry.totp, 1);502    }503    if (entry.tags && entry.tags.length > 0) {504      flags |= F_HAS_TAGS;505      writer.u8(Math.min(entry.tags.length, 0xff));506      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {507        writer.u16(table.intern(entry.tags[i]));508      }509    }510    if (entry.notes) {511      flags |= F_HAS_NOTES;512      writer.u16(table.intern(entry.notes));513    }514    if (entry.canary) {515      flags |= F_HAS_CANARY;516      writer.u16(table.intern(entry.canary.webhookUrl));517      writer.u16(table.intern(entry.canary.label));518    }519    if (entry.passwordChangedAt) {520      flags |= F_HAS_PWCHANGED;521      const pwSec = Math.floor(entry.passwordChangedAt / 1000);522      writer.u32(Math.max(0, pwSec - createdSec));523    }524    if (typeof entry.breach === "number" && entry.breach > 0) {525      flags |= F_HAS_BREACH;526      writer.u32(entry.breach);527    }528    if (entry.additionalUrls && entry.additionalUrls.length > 0) {529      flags |= F_HAS_ADDITIONAL_URLS;530      writer.u8(Math.min(entry.additionalUrls.length, 0xff));531      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {532        writer.u16(table.intern(entry.additionalUrls[i]));533      }534    }535  } else if (type === ENTRY_TYPE_NOTE) {536    if (entry.notes) {537      flags |= F_HAS_NOTES;538      writer.u16(table.intern(entry.notes));539    }540    if (entry.tags && entry.tags.length > 0) {541      flags |= F_HAS_TAGS;542      writer.u8(Math.min(entry.tags.length, 0xff));543      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {544        writer.u16(table.intern(entry.tags[i]));545      }546    }547  } else if (type === ENTRY_TYPE_CARD) {548    writer.u16(table.intern(entry.cardholderName));549    writer.lpString(entry.cardNumber ?? "", 1);550    writer.lpString(entry.expMonth ?? "", 1);551    writer.lpString(entry.expYear ?? "", 1);552    writer.lpString(entry.cvc ?? "", 1);553    writer.u16(table.intern(entry.cardNickname));554    writer.u8(networkToByte(entry.network));555    writer.u16(table.intern(entry.last4));556    if (entry.tags && entry.tags.length > 0) {557      flags |= F_HAS_TAGS;558      writer.u8(Math.min(entry.tags.length, 0xff));559      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {560        writer.u16(table.intern(entry.tags[i]));561      }562    }563    if (entry.notes) {564      flags |= F_HAS_NOTES;565      writer.u16(table.intern(entry.notes));566    }567  } else if (type === ENTRY_TYPE_PASSKEY) {568    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.569    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.570    const pk = entry.passkey;571    const flat = entry as unknown as Record<string, unknown>;572    const blob = encodeJsonSection({573      rpId:             pk ? pk.rpId             : flat["rpId"],574      rpName:           pk ? pk.rpName           : flat["rpName"],575      credentialId:     pk ? pk.credentialId     : flat["credentialId"],576      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],577      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],578      userHandle:       pk ? pk.userHandle       : flat["userHandle"],579      userName:         pk ? pk.userName         : flat["userName"],580      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],581      algorithm:        pk ? pk.algorithm        : flat["algorithm"],582      signCount:        pk ? pk.signCount        : flat["signCount"],583      aaguid:           pk ? pk.aaguid           : flat["aaguid"],584      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],585      backupState:      pk ? pk.backupState      : flat["backupState"],586      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],587      // Preserve the FULL login field set for passkey-bearing login entries.588      // A v2.11+ passkey hangs off a normal login (type:"login" + passkey589      // sub-object), so it can carry a password, TOTP seed, notes, tags, email,590      // canary, additional URLs, and the password-age stamp — all of which were591      // silently dropped on export before this (SEV-1-B). The blob is JSON, so592      // adding keys is backward-compatible: old decoders ignore unknown keys593      // and old exports simply lack them. Only emit these when `pk` is set;594      // pre-v2.11 flat passkey entries had no login fields to preserve.595      ...(pk ? {596        ...(entry.password          !== undefined ? { password:          entry.password } : {}),597        ...(entry.url               !== undefined ? { url:               entry.url } : {}),598        ...(entry.username          !== undefined ? { username:          entry.username } : {}),599        ...(entry.email             !== undefined ? { email:             entry.email } : {}),600        ...(entry.totp              !== undefined ? { totp:              entry.totp } : {}),601        ...(entry.notes             !== undefined ? { notes:             entry.notes } : {}),602        ...(entry.tags              !== undefined ? { tags:              entry.tags } : {}),603        ...(entry.canary            !== undefined ? { canary:            entry.canary } : {}),604        ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),605        ...(entry.breach            !== undefined ? { breach:            entry.breach } : {}),606        ...(entry.additionalUrls    !== undefined ? { additionalUrls:    entry.additionalUrls } : {}),607      } : {}),608    });609    writer.u32(blob.length);610    writer.bytes(blob);611  }612613  writer.setU8(flagOffset, flags);614  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);615  return writer.toBytes();616}617618function decodeEntry(619  data: Uint8Array,620  start: number,621  strings: string[],622  formatVersion: number623): { entry: VaultEntry; cursor: number } {624  const reader = new BinaryReader(data, start);625  const type = reader.u8();626  const id = bytesToUuid(reader.read(16));627  const flags = reader.u8();628  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;629  const createdSec = reader.u32();630  const updatedDelta = reader.u32();631  const deletedSec = reader.u32();632  const titleIdx = reader.u16();633634  const createdAt = createdSec * 1000;635  const modifiedAt = (createdSec + updatedDelta) * 1000;636  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;637  const title = lookupString(strings, titleIdx) ?? "";638639  const baseEntry: Partial<VaultEntry> = {640    id,641    title,642    createdAt,643    modifiedAt,644    ...(deletedAt !== undefined ? { deletedAt } : {}),645  };646647  if (type === ENTRY_TYPE_LOGIN) {648    const urlIdx = reader.u16();649    const usernameIdx = reader.u16();650    const password = reader.lpString(1);651    const tail: Partial<VaultEntry> = {};652653    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);654655    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);656    if (flags & F_HAS_TAGS) {657      const cnt = reader.u8();658      const tags: string[] = [];659      for (let i = 0; i < cnt; i++) {660        const v = lookupString(strings, reader.u16());661        if (v !== undefined) tags.push(v);662      }663      tail.tags = tags;664    }665    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());666    if (flags & F_HAS_CANARY) {667      const webhookUrl = lookupString(strings, reader.u16()) ?? "";668      const label = lookupString(strings, reader.u16());669      const canary: Canary = { webhookUrl };670      if (label !== undefined) canary.label = label;671      tail.canary = canary;672    }673    if (flags & F_HAS_PWCHANGED) {674      const pwDelta = reader.u32();675      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;676    }677    if (flags & F_HAS_BREACH) tail.breach = reader.u32();678    if (flags & F_HAS_ADDITIONAL_URLS) {679      const cnt = reader.u8();680      const urls: string[] = [];681      for (let i = 0; i < cnt; i++) {682        const v = lookupString(strings, reader.u16());683        if (v !== undefined) urls.push(v);684      }685      tail.additionalUrls = urls;686    }687688    return {689      entry: {690        ...(baseEntry as VaultEntry),691        type: "login",692        url: lookupString(strings, urlIdx),693        username: lookupString(strings, usernameIdx),694        password,695        ...tail,696      } as VaultEntry,697      cursor: reader.cursor,698    };699  }700701  if (type === ENTRY_TYPE_NOTE) {702    const tail: Partial<VaultEntry> = {};703    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());704    if (flags & F_HAS_TAGS) {705      const cnt = reader.u8();706      const tags: string[] = [];707      for (let i = 0; i < cnt; i++) {708        const v = lookupString(strings, reader.u16());709        if (v !== undefined) tags.push(v);710      }711      tail.tags = tags;712    }713    return {714      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,715      cursor: reader.cursor,716    };717  }718719  if (type === ENTRY_TYPE_CARD) {720    const cardholderIdx = reader.u16();721    const cardNumber = reader.lpString(1);722    const expMonth = reader.lpString(1);723    const expYear = reader.lpString(1);724    const cvc = reader.lpString(1);725    const nicknameIdx = reader.u16();726    const network = networkFromByte(reader.u8());727    const last4Idx = reader.u16();728    const tail: Partial<VaultEntry> = {};729    if (flags & F_HAS_TAGS) {730      const cnt = reader.u8();731      const tags: string[] = [];732      for (let i = 0; i < cnt; i++) {733        const v = lookupString(strings, reader.u16());734        if (v !== undefined) tags.push(v);735      }736      tail.tags = tags;737    }738    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());739740    return {741      entry: {742        ...(baseEntry as VaultEntry),743        type: "card",744        cardholderName: lookupString(strings, cardholderIdx),745        cardNumber,746        expMonth,747        expYear,748        cvc,749        cardNickname: lookupString(strings, nicknameIdx),750        network,751        last4: lookupString(strings, last4Idx),752        ...tail,753      } as VaultEntry,754      cursor: reader.cursor,755    };756  }757758  if (type === ENTRY_TYPE_PASSKEY) {759    const blobLen = reader.u32();760    const blob = reader.read(blobLen);761    const obj = decodeJsonSection<Record<string, unknown>>(blob);762    return {763      entry: {764        ...(baseEntry as VaultEntry),765        type: "passkey",766        ...obj,767      } as VaultEntry,768      cursor: reader.cursor,769    };770  }771772  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);773}774775function entryTypeByte(entry: VaultEntry): number {776  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys777  // as type:"login" entries with a nested passkey sub-object, so the type778  // field alone is not sufficient to detect them.779  if (entry.passkey) return ENTRY_TYPE_PASSKEY;780  switch (entry.type) {781    case "note":782      return ENTRY_TYPE_NOTE;783    case "card":784      return ENTRY_TYPE_CARD;785    case "passkey":786      return ENTRY_TYPE_PASSKEY;787    default:788      return ENTRY_TYPE_LOGIN;789  }790}791792function networkToByte(network: CardNetwork | undefined): number {793  if (!network) return 0xff;794  const idx = NETWORK_VALUES.indexOf(network);795  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");796}797798function networkFromByte(b: number): CardNetwork | undefined {799  if (b === 0xff) return undefined;800  return NETWORK_VALUES[b] ?? "unknown";801}802803// ---------------------------------------------------------------------------804// UUID packing — 16 raw bytes vs 36-character hyphenated string805// ---------------------------------------------------------------------------806807export function uuidToBytes(uuid: string): Uint8Array {808  const hex = uuid.replace(/-/g, "");809  if (hex.length !== 32) throw new Error("UUID_FORMAT");810  const out = new Uint8Array(16);811  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);812  return out;813}814815export function bytesToUuid(bytes: Uint8Array): string {816  if (bytes.length !== 16) throw new Error("UUID_LENGTH");817  const hex = Array.from(bytes)818    .map((b) => b.toString(16).padStart(2, "0"))819    .join("");820  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;821}822823// ---------------------------------------------------------------------------824// Tiny binary reader/writer825// ---------------------------------------------------------------------------826827class BinaryWriter {828  private chunks: Uint8Array[] = [];829  private len = 0;830831  get length(): number {832    return this.len;833  }834835  u8(v: number): void {836    const b = new Uint8Array(1);837    b[0] = v & 0xff;838    this.push(b);839  }840841  setU8(offset: number, v: number): void {842    // Only used to back-patch the flags byte; always lands inside chunks[1] or843    // earlier. Reconstruct by walking chunks until we hit the offset.844    let cursor = 0;845    for (const c of this.chunks) {846      if (offset < cursor + c.length) {847        c[offset - cursor] = v & 0xff;848        return;849      }850      cursor += c.length;851    }852    throw new Error("setU8 out of range");853  }854855  u16(v: number): void {856    const b = new Uint8Array(2);857    new DataView(b.buffer).setUint16(0, v & 0xffff, true);858    this.push(b);859  }860861  u32(v: number): void {862    const b = new Uint8Array(4);863    new DataView(b.buffer).setUint32(0, v >>> 0, true);864    this.push(b);865  }866867  bytes(b: Uint8Array): void {868    this.push(b);869  }870871  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */872  lpString(value: string, lenSize: 1 | 2): void {873    const enc = new TextEncoder().encode(value);874    if (lenSize === 1) {875      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");876      this.u8(enc.length);877    } else {878      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");879      this.u16(enc.length);880    }881    this.push(enc);882  }883884  toBytes(): Uint8Array {885    const out = new Uint8Array(this.len);886    let cursor = 0;887    for (const c of this.chunks) {888      out.set(c, cursor);889      cursor += c.length;890    }891    return out;892  }893894  private push(b: Uint8Array): void {895    this.chunks.push(b);896    this.len += b.length;897  }898}899900class BinaryReader {901  cursor: number;902  private view: DataView;903  constructor(private data: Uint8Array, start: number) {904    this.cursor = start;905    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);906  }907  u8(): number {908    return this.data[this.cursor++];909  }910  u16(): number {911    const v = this.view.getUint16(this.cursor, true);912    this.cursor += 2;913    return v;914  }915  u32(): number {916    const v = this.view.getUint32(this.cursor, true);917    this.cursor += 4;918    return v;919  }920  read(n: number): Uint8Array {921    const out = this.data.subarray(this.cursor, this.cursor + n);922    this.cursor += n;923    return out;924  }925  lpString(lenSize: 1 | 2): string {926    const len = lenSize === 1 ? this.u8() : this.u16();927    return new TextDecoder().decode(this.read(len));928  }929}930931// ---------------------------------------------------------------------------932// Crypto helpers933// ---------------------------------------------------------------------------934935async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {936  return argon2id({937    password: passphrase,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}