peachPayload.ts

33.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 type {36  Canary,37  CardNetwork,38  ManualSummonRecord,39  PeachLicenseExport,40  VaultEntry,41} from "../shared/types";42import type { VaultSettings } from "../background/vault";4344// ---------------------------------------------------------------------------45// Header / section constants46// ---------------------------------------------------------------------------4748export const MAGIC = 0x50434853; // "PCHS"49export const VERSION = 0x01;50export const HEADER_LEN = 8;51export const CHECKSUM_LEN = 4;5253export const SALT_LEN = 16;54export const NONCE_LEN = 12;55export const TAG_LEN = 16;5657// Size buckets (bytes) for payload padding. Coarsens ciphertext-size leakage58// so an attacker cannot infer vault size from file size. The bucket boundaries59// are powers of 4 starting at 1 KiB.60const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;6162// Pad `data` to the next SIZE_BUCKET boundary. The returned Uint8Array is63// zero-filled in the padding region — safe because it lives inside the AEAD.64function padToNearestBucket(data: Uint8Array): Uint8Array {65  const bucket = SIZE_BUCKETS.find((b) => b >= data.length);66  let targetSize: number;67  if (bucket !== undefined) {68    targetSize = bucket;69  } else {70    // Larger than the largest bucket — round up to the next power of two.71    targetSize = 262144;72    while (targetSize < data.length) targetSize *= 2;73  }74  if (targetSize === data.length) return data;75  const padded = new Uint8Array(targetSize);76  padded.set(data);77  return padded;78}7980// v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC81// and refuse gracefully; new clients detect v2 by this leading byte).82const FORMAT_V2 = 0x02;83// v3 format byte. Same envelope as v2 (length-prefixed inner + bucket84// padding) but login entries carry an extra `flags2` byte and an optional85// email field. Decoders fall back to v2 behavior for older payloads.86const FORMAT_V3 = 0x03;8788const ARGON2_MEMORY_KIB = 65536; // 64 MiB89const ARGON2_ITERATIONS = 3;90const ARGON2_PARALLELISM = 4;9192const SECTION_ENTRIES = 0x01;93const SECTION_SETTINGS = 0x02;94const SECTION_LICENSE = 0x03;95const SECTION_RECALL = 0x04;96const SECTION_DECLINED_CARDS = 0x05;9798const ENTRY_TYPE_LOGIN = 0x01;99const ENTRY_TYPE_NOTE = 0x02;100const ENTRY_TYPE_CARD = 0x03;101const ENTRY_TYPE_PASSKEY = 0x04;102103// Login entry flag bits104const F_HAS_TOTP = 1 << 0;105const F_HAS_TAGS = 1 << 1;106const F_HAS_NOTES = 1 << 2;107const F_HAS_CANARY = 1 << 3;108const F_HAS_PWCHANGED = 1 << 4;109const F_HAS_BREACH = 1 << 5;110const F_HAS_ADDITIONAL_URLS = 1 << 6;111const F_HAS_DELETED = 1 << 7;112113// v3-only flags2 byte (login entries only). Reserves room for further114// post-v3.6.82 fields without another envelope bump.115//116// CAUTION: flags2 bits are read positionally within FORMAT_V3. Adding a new117// boolean flag bit here does NOT require a format bump. But adding a new118// VARIABLE-LENGTH field gated by a new flag bit DOES — older v3 decoders don't119// know to skip the new field and the read cursor desyncs for everything after120// it. If you need a new variable-length field, bump FORMAT_V3 → FORMAT_V4 and121// gate the decoder on the format byte. Only add single-bit booleans to flags2.122const F2_HAS_EMAIL = 1 << 0;123124const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];125126// ---------------------------------------------------------------------------127// Public API128// ---------------------------------------------------------------------------129130export interface BuildPayloadInput {131  entries: VaultEntry[];132  settings: VaultSettings;133  license?: PeachLicenseExport;134  /** Optional declined card fingerprints (subset of settings — split out per spec). */135  declinedCardFingerprints?: string[];136  /** Optional recall map (subset of settings — split out per spec). */137  manualSummonDomains?: Record<string, ManualSummonRecord>;138  passphrase: string;139}140141export interface ParsePayloadOutput {142  entries: VaultEntry[];143  settings: VaultSettings;144  license?: PeachLicenseExport;145  declinedCardFingerprints?: string[];146  manualSummonDomains?: Record<string, ManualSummonRecord>;147}148149/**150 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →151 * AES-256-GCM encrypt under an Argon2id-derived key. Returns152 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into153 * `encodePeachScript` or the QR matrix encoder.154 */155export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {156  const sections: Uint8Array[] = [];157158  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));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  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]189  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and190  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent191  // wrong-password failure. Same envelope as v2; v3 adds optional email +192  // flags2 byte on login entries (see encodeEntry/decodeEntry).193  const header5 = new Uint8Array(5);194  header5[0] = FORMAT_V3;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  const key = await deriveKey(passphrase, salt);229  let decrypted: Uint8Array;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  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by239  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.240  let inner: Uint8Array;241  let formatVersion: number = 1;242  if (243    decrypted.length >= 5 &&244    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)245  ) {246    formatVersion = decrypted[0];247    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);248    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");249    inner = decrypted.subarray(5, 5 + innerLen);250  } else {251    inner = decrypted; // v1 format — no length prefix, no padding252  }253254  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");255256  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);257  const stored = inner.subarray(inner.length - CHECKSUM_LEN);258  const computed = await blake3Truncated(body);259  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");260261  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);262  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");263  const version = body[4];264  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);265  const sectionCount = body[5];266267  let cursor = HEADER_LEN;268  const out: ParsePayloadOutput = {269    entries: [],270    settings: {},271  };272  for (let i = 0; i < sectionCount; i++) {273    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");274    const type = body[cursor];275    const length = view.getUint32(cursor + 1, true);276    const clength = view.getUint32(cursor + 5, true);277    const dataStart = cursor + 9;278    const dataEnd = dataStart + clength;279    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");280    const compressed = body.subarray(dataStart, dataEnd);281    const data = await decompressZstd(compressed);282    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");283284    switch (type) {285      case SECTION_ENTRIES:286        out.entries = decodeEntriesSection(data, formatVersion);287        break;288      case SECTION_SETTINGS:289        out.settings = decodeJsonSection<VaultSettings>(data);290        break;291      case SECTION_LICENSE:292        out.license = decodeJsonSection<PeachLicenseExport>(data);293        break;294      case SECTION_RECALL:295        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);296        break;297      case SECTION_DECLINED_CARDS:298        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);299        break;300      default:301        // Unknown section — skip rather than fail, so future-introduced302        // sections do not break older clients reading newer payloads.303        break;304    }305306    cursor = dataEnd;307  }308309  return out;310}311312// ---------------------------------------------------------------------------313// Section assembly314// ---------------------------------------------------------------------------315316async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {317  const compressed = await compressZstd(raw, 19);318  const out = new Uint8Array(9 + compressed.length);319  const view = new DataView(out.buffer);320  out[0] = type;321  view.setUint32(1, raw.length, true);322  view.setUint32(5, compressed.length, true);323  out.set(compressed, 9);324  return out;325}326327function encodeJsonSection(value: unknown): Uint8Array {328  return new TextEncoder().encode(JSON.stringify(value));329}330331function decodeJsonSection<T>(data: Uint8Array): T {332  return JSON.parse(new TextDecoder().decode(data)) as T;333}334335// ---------------------------------------------------------------------------336// Entries section: shared string table + per-type packed bodies337// ---------------------------------------------------------------------------338339class StringTableBuilder {340  private map = new Map<string, number>();341  private list: string[] = [];342343  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */344  intern(value: string | undefined): number {345    if (!value) return 0;346    const existing = this.map.get(value);347    if (existing !== undefined) return existing;348    const idx = this.list.length + 1; // 1-indexed; 0 = absent349    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");350    this.map.set(value, idx);351    this.list.push(value);352    return idx;353  }354355  serialize(): Uint8Array {356    const enc = new TextEncoder();357    const encoded = this.list.map((s) => enc.encode(s));358    let total = 2; // u16 count359    for (const e of encoded) total += 2 + e.length;360    const out = new Uint8Array(total);361    const view = new DataView(out.buffer);362    let cursor = 0;363    view.setUint16(cursor, encoded.length, true);364    cursor += 2;365    for (const e of encoded) {366      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");367      view.setUint16(cursor, e.length, true);368      cursor += 2;369      out.set(e, cursor);370      cursor += e.length;371    }372    return out;373  }374}375376function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {377  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);378  const count = view.getUint16(cursor, true);379  cursor += 2;380  const strings: string[] = [];381  const dec = new TextDecoder();382  for (let i = 0; i < count; i++) {383    const len = view.getUint16(cursor, true);384    cursor += 2;385    strings.push(dec.decode(data.subarray(cursor, cursor + len)));386    cursor += len;387  }388  return { strings, cursor };389}390391function lookupString(strings: string[], idx: number): string | undefined {392  if (idx === 0) return undefined;393  return strings[idx - 1];394}395396function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {397  const table = new StringTableBuilder();398  const bodies: Uint8Array[] = [];399400  // Pre-intern title for every entry so the table is built consistently.401  for (const entry of entries) table.intern(entry.title);402403  for (const entry of entries) {404    bodies.push(encodeEntry(entry, table, formatVersion));405  }406407  const tableBytes = table.serialize();408409  // [u32 count][string_table][entries...]410  let bodiesLen = 0;411  for (const b of bodies) bodiesLen += b.length;412  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);413  const view = new DataView(out.buffer);414  view.setUint32(0, entries.length, true);415  out.set(tableBytes, 4);416  let cursor = 4 + tableBytes.length;417  for (const b of bodies) {418    out.set(b, cursor);419    cursor += b.length;420  }421  return out;422}423424function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {425  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);426  const count = view.getUint32(0, true);427  const { strings, cursor: afterTable } = readStringTable(data, 4);428429  const out: VaultEntry[] = [];430  let cursor = afterTable;431  for (let i = 0; i < count; i++) {432    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);433    out.push(entry);434    cursor = next;435  }436  return out;437}438439// ---------------------------------------------------------------------------440// Per-entry packing441// ---------------------------------------------------------------------------442443function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {444  const writer = new BinaryWriter();445  const type = entryTypeByte(entry);446  writer.u8(type);447  writer.bytes(uuidToBytes(entry.id));448449  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx450  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);451  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);452  const updatedDelta = Math.max(0, updatedSec - createdSec);453  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;454455  // flags placeholder (we'll overwrite at the end)456  const flagOffset = writer.length;457  writer.u8(0);458  // v3-only flags2 placeholder, written for every entry so the section459  // layout stays uniform across types.460  let flags2Offset = -1;461  if (formatVersion === FORMAT_V3) {462    flags2Offset = writer.length;463    writer.u8(0);464  }465466  writer.u32(createdSec);467  writer.u32(updatedDelta);468  writer.u32(deletedSec);469  writer.u16(table.intern(entry.title));470471  let flags = 0;472  let flags2 = 0;473  if (entry.deletedAt) flags |= F_HAS_DELETED;474475  if (type === ENTRY_TYPE_LOGIN) {476    writer.u16(table.intern(entry.url));477    writer.u16(table.intern(entry.username));478    writer.lpString(entry.password ?? "", 1);479    if (formatVersion === FORMAT_V3 && entry.email) {480      flags2 |= F2_HAS_EMAIL;481      writer.lpString(entry.email, 1);482    }483484    if (entry.totp) {485      flags |= F_HAS_TOTP;486      writer.lpString(entry.totp, 1);487    }488    if (entry.tags && entry.tags.length > 0) {489      flags |= F_HAS_TAGS;490      writer.u8(Math.min(entry.tags.length, 0xff));491      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {492        writer.u16(table.intern(entry.tags[i]));493      }494    }495    if (entry.notes) {496      flags |= F_HAS_NOTES;497      writer.u16(table.intern(entry.notes));498    }499    if (entry.canary) {500      flags |= F_HAS_CANARY;501      writer.u16(table.intern(entry.canary.webhookUrl));502      writer.u16(table.intern(entry.canary.label));503    }504    if (entry.passwordChangedAt) {505      flags |= F_HAS_PWCHANGED;506      const pwSec = Math.floor(entry.passwordChangedAt / 1000);507      writer.u32(Math.max(0, pwSec - createdSec));508    }509    if (typeof entry.breach === "number" && entry.breach > 0) {510      flags |= F_HAS_BREACH;511      writer.u32(entry.breach);512    }513    if (entry.additionalUrls && entry.additionalUrls.length > 0) {514      flags |= F_HAS_ADDITIONAL_URLS;515      writer.u8(Math.min(entry.additionalUrls.length, 0xff));516      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {517        writer.u16(table.intern(entry.additionalUrls[i]));518      }519    }520  } else if (type === ENTRY_TYPE_NOTE) {521    if (entry.notes) {522      flags |= F_HAS_NOTES;523      writer.u16(table.intern(entry.notes));524    }525    if (entry.tags && entry.tags.length > 0) {526      flags |= F_HAS_TAGS;527      writer.u8(Math.min(entry.tags.length, 0xff));528      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {529        writer.u16(table.intern(entry.tags[i]));530      }531    }532  } else if (type === ENTRY_TYPE_CARD) {533    writer.u16(table.intern(entry.cardholderName));534    writer.lpString(entry.cardNumber ?? "", 1);535    writer.lpString(entry.expMonth ?? "", 1);536    writer.lpString(entry.expYear ?? "", 1);537    writer.lpString(entry.cvc ?? "", 1);538    writer.u16(table.intern(entry.cardNickname));539    writer.u8(networkToByte(entry.network));540    writer.u16(table.intern(entry.last4));541    if (entry.tags && entry.tags.length > 0) {542      flags |= F_HAS_TAGS;543      writer.u8(Math.min(entry.tags.length, 0xff));544      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {545        writer.u16(table.intern(entry.tags[i]));546      }547    }548    if (entry.notes) {549      flags |= F_HAS_NOTES;550      writer.u16(table.intern(entry.notes));551    }552  } else if (type === ENTRY_TYPE_PASSKEY) {553    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.554    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.555    const pk = entry.passkey;556    const flat = entry as unknown as Record<string, unknown>;557    const blob = encodeJsonSection({558      rpId:             pk ? pk.rpId             : flat["rpId"],559      rpName:           pk ? pk.rpName           : flat["rpName"],560      credentialId:     pk ? pk.credentialId     : flat["credentialId"],561      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],562      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],563      userHandle:       pk ? pk.userHandle       : flat["userHandle"],564      userName:         pk ? pk.userName         : flat["userName"],565      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],566      algorithm:        pk ? pk.algorithm        : flat["algorithm"],567      signCount:        pk ? pk.signCount        : flat["signCount"],568      aaguid:           pk ? pk.aaguid           : flat["aaguid"],569      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],570      backupState:      pk ? pk.backupState      : flat["backupState"],571      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],572      // Preserve login fields for passkey-bearing login entries.573      ...(pk && entry.password  !== undefined ? { password:  entry.password  } : {}),574      ...(pk && entry.url       !== undefined ? { url:       entry.url       } : {}),575      ...(pk && entry.username  !== undefined ? { username:  entry.username  } : {}),576    });577    writer.u32(blob.length);578    writer.bytes(blob);579  }580581  writer.setU8(flagOffset, flags);582  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);583  return writer.toBytes();584}585586function decodeEntry(587  data: Uint8Array,588  start: number,589  strings: string[],590  formatVersion: number591): { entry: VaultEntry; cursor: number } {592  const reader = new BinaryReader(data, start);593  const type = reader.u8();594  const id = bytesToUuid(reader.read(16));595  const flags = reader.u8();596  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;597  const createdSec = reader.u32();598  const updatedDelta = reader.u32();599  const deletedSec = reader.u32();600  const titleIdx = reader.u16();601602  const createdAt = createdSec * 1000;603  const modifiedAt = (createdSec + updatedDelta) * 1000;604  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;605  const title = lookupString(strings, titleIdx) ?? "";606607  const baseEntry: Partial<VaultEntry> = {608    id,609    title,610    createdAt,611    modifiedAt,612    ...(deletedAt !== undefined ? { deletedAt } : {}),613  };614615  if (type === ENTRY_TYPE_LOGIN) {616    const urlIdx = reader.u16();617    const usernameIdx = reader.u16();618    const password = reader.lpString(1);619    const tail: Partial<VaultEntry> = {};620621    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);622623    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);624    if (flags & F_HAS_TAGS) {625      const cnt = reader.u8();626      const tags: string[] = [];627      for (let i = 0; i < cnt; i++) {628        const v = lookupString(strings, reader.u16());629        if (v !== undefined) tags.push(v);630      }631      tail.tags = tags;632    }633    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());634    if (flags & F_HAS_CANARY) {635      const webhookUrl = lookupString(strings, reader.u16()) ?? "";636      const label = lookupString(strings, reader.u16());637      const canary: Canary = { webhookUrl };638      if (label !== undefined) canary.label = label;639      tail.canary = canary;640    }641    if (flags & F_HAS_PWCHANGED) {642      const pwDelta = reader.u32();643      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;644    }645    if (flags & F_HAS_BREACH) tail.breach = reader.u32();646    if (flags & F_HAS_ADDITIONAL_URLS) {647      const cnt = reader.u8();648      const urls: string[] = [];649      for (let i = 0; i < cnt; i++) {650        const v = lookupString(strings, reader.u16());651        if (v !== undefined) urls.push(v);652      }653      tail.additionalUrls = urls;654    }655656    return {657      entry: {658        ...(baseEntry as VaultEntry),659        type: "login",660        url: lookupString(strings, urlIdx),661        username: lookupString(strings, usernameIdx),662        password,663        ...tail,664      } as VaultEntry,665      cursor: reader.cursor,666    };667  }668669  if (type === ENTRY_TYPE_NOTE) {670    const tail: Partial<VaultEntry> = {};671    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());672    if (flags & F_HAS_TAGS) {673      const cnt = reader.u8();674      const tags: string[] = [];675      for (let i = 0; i < cnt; i++) {676        const v = lookupString(strings, reader.u16());677        if (v !== undefined) tags.push(v);678      }679      tail.tags = tags;680    }681    return {682      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,683      cursor: reader.cursor,684    };685  }686687  if (type === ENTRY_TYPE_CARD) {688    const cardholderIdx = reader.u16();689    const cardNumber = reader.lpString(1);690    const expMonth = reader.lpString(1);691    const expYear = reader.lpString(1);692    const cvc = reader.lpString(1);693    const nicknameIdx = reader.u16();694    const network = networkFromByte(reader.u8());695    const last4Idx = reader.u16();696    const tail: Partial<VaultEntry> = {};697    if (flags & F_HAS_TAGS) {698      const cnt = reader.u8();699      const tags: string[] = [];700      for (let i = 0; i < cnt; i++) {701        const v = lookupString(strings, reader.u16());702        if (v !== undefined) tags.push(v);703      }704      tail.tags = tags;705    }706    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());707708    return {709      entry: {710        ...(baseEntry as VaultEntry),711        type: "card",712        cardholderName: lookupString(strings, cardholderIdx),713        cardNumber,714        expMonth,715        expYear,716        cvc,717        cardNickname: lookupString(strings, nicknameIdx),718        network,719        last4: lookupString(strings, last4Idx),720        ...tail,721      } as VaultEntry,722      cursor: reader.cursor,723    };724  }725726  if (type === ENTRY_TYPE_PASSKEY) {727    const blobLen = reader.u32();728    const blob = reader.read(blobLen);729    const obj = decodeJsonSection<Record<string, unknown>>(blob);730    return {731      entry: {732        ...(baseEntry as VaultEntry),733        type: "passkey",734        ...obj,735      } as VaultEntry,736      cursor: reader.cursor,737    };738  }739740  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);741}742743function entryTypeByte(entry: VaultEntry): number {744  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys745  // as type:"login" entries with a nested passkey sub-object, so the type746  // field alone is not sufficient to detect them.747  if (entry.passkey) return ENTRY_TYPE_PASSKEY;748  switch (entry.type) {749    case "note":750      return ENTRY_TYPE_NOTE;751    case "card":752      return ENTRY_TYPE_CARD;753    case "passkey":754      return ENTRY_TYPE_PASSKEY;755    default:756      return ENTRY_TYPE_LOGIN;757  }758}759760function networkToByte(network: CardNetwork | undefined): number {761  if (!network) return 0xff;762  const idx = NETWORK_VALUES.indexOf(network);763  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");764}765766function networkFromByte(b: number): CardNetwork | undefined {767  if (b === 0xff) return undefined;768  return NETWORK_VALUES[b] ?? "unknown";769}770771// ---------------------------------------------------------------------------772// UUID packing — 16 raw bytes vs 36-character hyphenated string773// ---------------------------------------------------------------------------774775export function uuidToBytes(uuid: string): Uint8Array {776  const hex = uuid.replace(/-/g, "");777  if (hex.length !== 32) throw new Error("UUID_FORMAT");778  const out = new Uint8Array(16);779  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);780  return out;781}782783export function bytesToUuid(bytes: Uint8Array): string {784  if (bytes.length !== 16) throw new Error("UUID_LENGTH");785  const hex = Array.from(bytes)786    .map((b) => b.toString(16).padStart(2, "0"))787    .join("");788  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;789}790791// ---------------------------------------------------------------------------792// Tiny binary reader/writer793// ---------------------------------------------------------------------------794795class BinaryWriter {796  private chunks: Uint8Array[] = [];797  private len = 0;798799  get length(): number {800    return this.len;801  }802803  u8(v: number): void {804    const b = new Uint8Array(1);805    b[0] = v & 0xff;806    this.push(b);807  }808809  setU8(offset: number, v: number): void {810    // Only used to back-patch the flags byte; always lands inside chunks[1] or811    // earlier. Reconstruct by walking chunks until we hit the offset.812    let cursor = 0;813    for (const c of this.chunks) {814      if (offset < cursor + c.length) {815        c[offset - cursor] = v & 0xff;816        return;817      }818      cursor += c.length;819    }820    throw new Error("setU8 out of range");821  }822823  u16(v: number): void {824    const b = new Uint8Array(2);825    new DataView(b.buffer).setUint16(0, v & 0xffff, true);826    this.push(b);827  }828829  u32(v: number): void {830    const b = new Uint8Array(4);831    new DataView(b.buffer).setUint32(0, v >>> 0, true);832    this.push(b);833  }834835  bytes(b: Uint8Array): void {836    this.push(b);837  }838839  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */840  lpString(value: string, lenSize: 1 | 2): void {841    const enc = new TextEncoder().encode(value);842    if (lenSize === 1) {843      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");844      this.u8(enc.length);845    } else {846      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");847      this.u16(enc.length);848    }849    this.push(enc);850  }851852  toBytes(): Uint8Array {853    const out = new Uint8Array(this.len);854    let cursor = 0;855    for (const c of this.chunks) {856      out.set(c, cursor);857      cursor += c.length;858    }859    return out;860  }861862  private push(b: Uint8Array): void {863    this.chunks.push(b);864    this.len += b.length;865  }866}867868class BinaryReader {869  cursor: number;870  private view: DataView;871  constructor(private data: Uint8Array, start: number) {872    this.cursor = start;873    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);874  }875  u8(): number {876    return this.data[this.cursor++];877  }878  u16(): number {879    const v = this.view.getUint16(this.cursor, true);880    this.cursor += 2;881    return v;882  }883  u32(): number {884    const v = this.view.getUint32(this.cursor, true);885    this.cursor += 4;886    return v;887  }888  read(n: number): Uint8Array {889    const out = this.data.subarray(this.cursor, this.cursor + n);890    this.cursor += n;891    return out;892  }893  lpString(lenSize: 1 | 2): string {894    const len = lenSize === 1 ? this.u8() : this.u16();895    return new TextDecoder().decode(this.read(len));896  }897}898899// ---------------------------------------------------------------------------900// Crypto helpers901// ---------------------------------------------------------------------------902903async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {904  return argon2id({905    password: passphrase,906    salt,907    parallelism: ARGON2_PARALLELISM,908    iterations: ARGON2_ITERATIONS,909    memorySize: ARGON2_MEMORY_KIB,910    hashLength: 32,911    outputType: "binary",912  });913}914915async function aesGcmEncrypt(916  key: Uint8Array,917  nonce: Uint8Array,918  plaintext: Uint8Array919): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {920  const cryptoKey = await crypto.subtle.importKey(921    "raw",922    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,923    { name: "AES-GCM" },924    false,925    ["encrypt"]926  );927  const raw = await crypto.subtle.encrypt(928    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },929    cryptoKey,930    plaintext.buffer.slice(931      plaintext.byteOffset,932      plaintext.byteOffset + plaintext.byteLength933    ) as ArrayBuffer934  );935  const out = new Uint8Array(raw);936  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };937}938939async function aesGcmDecrypt(940  key: Uint8Array,941  nonce: Uint8Array,942  ciphertext: Uint8Array,943  tag: Uint8Array944): Promise<Uint8Array> {945  const cryptoKey = await crypto.subtle.importKey(946    "raw",947    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,948    { name: "AES-GCM" },949    false,950    ["decrypt"]951  );952  const combined = concat([ciphertext, tag]);953  const raw = await crypto.subtle.decrypt(954    {955      name: "AES-GCM",956      iv: nonce.buffer.slice(957        nonce.byteOffset,958        nonce.byteOffset + nonce.byteLength959      ) as ArrayBuffer,960      tagLength: 128,961    },962    cryptoKey,963    combined.buffer.slice(964      combined.byteOffset,965      combined.byteOffset + combined.byteLength966    ) as ArrayBuffer967  );968  return new Uint8Array(raw);969}970971async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {972  const hex = await blake3(data);973  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.974  const out = new Uint8Array(CHECKSUM_LEN);975  for (let i = 0; i < CHECKSUM_LEN; i++) {976    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);977  }978  return out;979}980981function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {982  if (a.length !== b.length) return false;983  let diff = 0;984  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];985  return diff === 0;986}987988function concat(parts: Uint8Array[]): Uint8Array {989  let total = 0;990  for (const p of parts) total += p.length;991  const out = new Uint8Array(total);992  let cursor = 0;993  for (const p of parts) {994    out.set(p, cursor);995    cursor += p.length;996  }997  return out;998}