peachPayload.ts

32.8 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.115const F2_HAS_EMAIL = 1 << 0;116117const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];118119// ---------------------------------------------------------------------------120// Public API121// ---------------------------------------------------------------------------122123export interface BuildPayloadInput {124  entries: VaultEntry[];125  settings: VaultSettings;126  license?: PeachLicenseExport;127  /** Optional declined card fingerprints (subset of settings — split out per spec). */128  declinedCardFingerprints?: string[];129  /** Optional recall map (subset of settings — split out per spec). */130  manualSummonDomains?: Record<string, ManualSummonRecord>;131  passphrase: string;132}133134export interface ParsePayloadOutput {135  entries: VaultEntry[];136  settings: VaultSettings;137  license?: PeachLicenseExport;138  declinedCardFingerprints?: string[];139  manualSummonDomains?: Record<string, ManualSummonRecord>;140}141142/**143 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →144 * AES-256-GCM encrypt under an Argon2id-derived key. Returns145 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into146 * `encodePeachScript` or the QR matrix encoder.147 */148export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {149  const sections: Uint8Array[] = [];150151  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));152  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));153  if (input.license) {154    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));155  }156  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {157    sections.push(158      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))159    );160  }161  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {162    sections.push(163      await buildSection(164        SECTION_DECLINED_CARDS,165        encodeJsonSection(input.declinedCardFingerprints)166      )167    );168  }169170  const header = new Uint8Array(HEADER_LEN);171  const headerView = new DataView(header.buffer);172  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump173  header[4] = VERSION;174  header[5] = sections.length;175  // bytes 6,7: reserved (zero)176177  const body = concat([header, ...sections]);178  const checksum = await blake3Truncated(body);179  const inner = concat([body, checksum]);180181  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]182  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and183  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent184  // wrong-password failure. Same envelope as v2; v3 adds optional email +185  // flags2 byte on login entries (see encodeEntry/decodeEntry).186  const header5 = new Uint8Array(5);187  header5[0] = FORMAT_V3;188  new DataView(header5.buffer).setUint32(1, inner.length, true);189  const wrapped = padToNearestBucket(concat([header5, inner]));190191  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));192  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));193  const key = await deriveKey(input.passphrase, salt);194  try {195    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);196    return concat([salt, nonce, ciphertext, tag]);197  } finally {198    key.fill(0);199  }200}201202/**203 * Parse the encrypted envelope back to a typed payload. Throws204 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a205 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or206 * `CHECKSUM_FAILED` on a corrupt inner payload.207 */208export async function parsePayload(209  envelope: Uint8Array,210  passphrase: string211): Promise<ParsePayloadOutput> {212  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {213    throw new Error("PAYLOAD_TOO_SHORT");214  }215216  const salt = envelope.subarray(0, SALT_LEN);217  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);218  const tag = envelope.subarray(envelope.length - TAG_LEN);219  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);220221  const key = await deriveKey(passphrase, salt);222  let decrypted: Uint8Array;223  try {224    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);225  } catch {226    throw new Error("INVALID_PASSPHRASE");227  } finally {228    key.fill(0);229  }230231  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by232  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.233  let inner: Uint8Array;234  let formatVersion: number = 1;235  if (236    decrypted.length >= 5 &&237    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)238  ) {239    formatVersion = decrypted[0];240    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);241    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");242    inner = decrypted.subarray(5, 5 + innerLen);243  } else {244    inner = decrypted; // v1 format — no length prefix, no padding245  }246247  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");248249  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);250  const stored = inner.subarray(inner.length - CHECKSUM_LEN);251  const computed = await blake3Truncated(body);252  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");253254  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);255  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");256  const version = body[4];257  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);258  const sectionCount = body[5];259260  let cursor = HEADER_LEN;261  const out: ParsePayloadOutput = {262    entries: [],263    settings: {},264  };265  for (let i = 0; i < sectionCount; i++) {266    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");267    const type = body[cursor];268    const length = view.getUint32(cursor + 1, true);269    const clength = view.getUint32(cursor + 5, true);270    const dataStart = cursor + 9;271    const dataEnd = dataStart + clength;272    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");273    const compressed = body.subarray(dataStart, dataEnd);274    const data = await decompressZstd(compressed);275    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");276277    switch (type) {278      case SECTION_ENTRIES:279        out.entries = decodeEntriesSection(data, formatVersion);280        break;281      case SECTION_SETTINGS:282        out.settings = decodeJsonSection<VaultSettings>(data);283        break;284      case SECTION_LICENSE:285        out.license = decodeJsonSection<PeachLicenseExport>(data);286        break;287      case SECTION_RECALL:288        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);289        break;290      case SECTION_DECLINED_CARDS:291        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);292        break;293      default:294        // Unknown section — skip rather than fail, so future-introduced295        // sections do not break older clients reading newer payloads.296        break;297    }298299    cursor = dataEnd;300  }301302  return out;303}304305// ---------------------------------------------------------------------------306// Section assembly307// ---------------------------------------------------------------------------308309async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {310  const compressed = await compressZstd(raw, 19);311  const out = new Uint8Array(9 + compressed.length);312  const view = new DataView(out.buffer);313  out[0] = type;314  view.setUint32(1, raw.length, true);315  view.setUint32(5, compressed.length, true);316  out.set(compressed, 9);317  return out;318}319320function encodeJsonSection(value: unknown): Uint8Array {321  return new TextEncoder().encode(JSON.stringify(value));322}323324function decodeJsonSection<T>(data: Uint8Array): T {325  return JSON.parse(new TextDecoder().decode(data)) as T;326}327328// ---------------------------------------------------------------------------329// Entries section: shared string table + per-type packed bodies330// ---------------------------------------------------------------------------331332class StringTableBuilder {333  private map = new Map<string, number>();334  private list: string[] = [];335336  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */337  intern(value: string | undefined): number {338    if (!value) return 0;339    const existing = this.map.get(value);340    if (existing !== undefined) return existing;341    const idx = this.list.length + 1; // 1-indexed; 0 = absent342    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");343    this.map.set(value, idx);344    this.list.push(value);345    return idx;346  }347348  serialize(): Uint8Array {349    const enc = new TextEncoder();350    const encoded = this.list.map((s) => enc.encode(s));351    let total = 2; // u16 count352    for (const e of encoded) total += 2 + e.length;353    const out = new Uint8Array(total);354    const view = new DataView(out.buffer);355    let cursor = 0;356    view.setUint16(cursor, encoded.length, true);357    cursor += 2;358    for (const e of encoded) {359      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");360      view.setUint16(cursor, e.length, true);361      cursor += 2;362      out.set(e, cursor);363      cursor += e.length;364    }365    return out;366  }367}368369function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {370  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);371  const count = view.getUint16(cursor, true);372  cursor += 2;373  const strings: string[] = [];374  const dec = new TextDecoder();375  for (let i = 0; i < count; i++) {376    const len = view.getUint16(cursor, true);377    cursor += 2;378    strings.push(dec.decode(data.subarray(cursor, cursor + len)));379    cursor += len;380  }381  return { strings, cursor };382}383384function lookupString(strings: string[], idx: number): string | undefined {385  if (idx === 0) return undefined;386  return strings[idx - 1];387}388389function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {390  const table = new StringTableBuilder();391  const bodies: Uint8Array[] = [];392393  // Pre-intern title for every entry so the table is built consistently.394  for (const entry of entries) table.intern(entry.title);395396  for (const entry of entries) {397    bodies.push(encodeEntry(entry, table, formatVersion));398  }399400  const tableBytes = table.serialize();401402  // [u32 count][string_table][entries...]403  let bodiesLen = 0;404  for (const b of bodies) bodiesLen += b.length;405  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);406  const view = new DataView(out.buffer);407  view.setUint32(0, entries.length, true);408  out.set(tableBytes, 4);409  let cursor = 4 + tableBytes.length;410  for (const b of bodies) {411    out.set(b, cursor);412    cursor += b.length;413  }414  return out;415}416417function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {418  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);419  const count = view.getUint32(0, true);420  const { strings, cursor: afterTable } = readStringTable(data, 4);421422  const out: VaultEntry[] = [];423  let cursor = afterTable;424  for (let i = 0; i < count; i++) {425    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);426    out.push(entry);427    cursor = next;428  }429  return out;430}431432// ---------------------------------------------------------------------------433// Per-entry packing434// ---------------------------------------------------------------------------435436function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {437  const writer = new BinaryWriter();438  const type = entryTypeByte(entry);439  writer.u8(type);440  writer.bytes(uuidToBytes(entry.id));441442  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx443  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);444  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);445  const updatedDelta = Math.max(0, updatedSec - createdSec);446  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;447448  // flags placeholder (we'll overwrite at the end)449  const flagOffset = writer.length;450  writer.u8(0);451  // v3-only flags2 placeholder, written for every entry so the section452  // layout stays uniform across types.453  let flags2Offset = -1;454  if (formatVersion === FORMAT_V3) {455    flags2Offset = writer.length;456    writer.u8(0);457  }458459  writer.u32(createdSec);460  writer.u32(updatedDelta);461  writer.u32(deletedSec);462  writer.u16(table.intern(entry.title));463464  let flags = 0;465  let flags2 = 0;466  if (entry.deletedAt) flags |= F_HAS_DELETED;467468  if (type === ENTRY_TYPE_LOGIN) {469    writer.u16(table.intern(entry.url));470    writer.u16(table.intern(entry.username));471    writer.lpString(entry.password ?? "", 1);472    if (formatVersion === FORMAT_V3 && entry.email) {473      flags2 |= F2_HAS_EMAIL;474      writer.lpString(entry.email, 1);475    }476477    if (entry.totp) {478      flags |= F_HAS_TOTP;479      writer.lpString(entry.totp, 1);480    }481    if (entry.tags && entry.tags.length > 0) {482      flags |= F_HAS_TAGS;483      writer.u8(Math.min(entry.tags.length, 0xff));484      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {485        writer.u16(table.intern(entry.tags[i]));486      }487    }488    if (entry.notes) {489      flags |= F_HAS_NOTES;490      writer.u16(table.intern(entry.notes));491    }492    if (entry.canary) {493      flags |= F_HAS_CANARY;494      writer.u16(table.intern(entry.canary.webhookUrl));495      writer.u16(table.intern(entry.canary.label));496    }497    if (entry.passwordChangedAt) {498      flags |= F_HAS_PWCHANGED;499      const pwSec = Math.floor(entry.passwordChangedAt / 1000);500      writer.u32(Math.max(0, pwSec - createdSec));501    }502    if (typeof entry.breach === "number" && entry.breach > 0) {503      flags |= F_HAS_BREACH;504      writer.u32(entry.breach);505    }506    if (entry.additionalUrls && entry.additionalUrls.length > 0) {507      flags |= F_HAS_ADDITIONAL_URLS;508      writer.u8(Math.min(entry.additionalUrls.length, 0xff));509      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {510        writer.u16(table.intern(entry.additionalUrls[i]));511      }512    }513  } else if (type === ENTRY_TYPE_NOTE) {514    if (entry.notes) {515      flags |= F_HAS_NOTES;516      writer.u16(table.intern(entry.notes));517    }518    if (entry.tags && entry.tags.length > 0) {519      flags |= F_HAS_TAGS;520      writer.u8(Math.min(entry.tags.length, 0xff));521      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {522        writer.u16(table.intern(entry.tags[i]));523      }524    }525  } else if (type === ENTRY_TYPE_CARD) {526    writer.u16(table.intern(entry.cardholderName));527    writer.lpString(entry.cardNumber ?? "", 1);528    writer.lpString(entry.expMonth ?? "", 1);529    writer.lpString(entry.expYear ?? "", 1);530    writer.lpString(entry.cvc ?? "", 1);531    writer.u16(table.intern(entry.cardNickname));532    writer.u8(networkToByte(entry.network));533    writer.u16(table.intern(entry.last4));534    if (entry.tags && entry.tags.length > 0) {535      flags |= F_HAS_TAGS;536      writer.u8(Math.min(entry.tags.length, 0xff));537      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {538        writer.u16(table.intern(entry.tags[i]));539      }540    }541    if (entry.notes) {542      flags |= F_HAS_NOTES;543      writer.u16(table.intern(entry.notes));544    }545  } else if (type === ENTRY_TYPE_PASSKEY) {546    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.547    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.548    const pk = entry.passkey;549    const flat = entry as unknown as Record<string, unknown>;550    const blob = encodeJsonSection({551      rpId:             pk ? pk.rpId             : flat["rpId"],552      rpName:           pk ? pk.rpName           : flat["rpName"],553      credentialId:     pk ? pk.credentialId     : flat["credentialId"],554      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],555      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],556      userHandle:       pk ? pk.userHandle       : flat["userHandle"],557      userName:         pk ? pk.userName         : flat["userName"],558      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],559      algorithm:        pk ? pk.algorithm        : flat["algorithm"],560      signCount:        pk ? pk.signCount        : flat["signCount"],561      aaguid:           pk ? pk.aaguid           : flat["aaguid"],562      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],563      backupState:      pk ? pk.backupState      : flat["backupState"],564      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],565      // Preserve login fields for passkey-bearing login entries.566      ...(pk && entry.password  !== undefined ? { password:  entry.password  } : {}),567      ...(pk && entry.url       !== undefined ? { url:       entry.url       } : {}),568      ...(pk && entry.username  !== undefined ? { username:  entry.username  } : {}),569    });570    writer.u32(blob.length);571    writer.bytes(blob);572  }573574  writer.setU8(flagOffset, flags);575  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);576  return writer.toBytes();577}578579function decodeEntry(580  data: Uint8Array,581  start: number,582  strings: string[],583  formatVersion: number584): { entry: VaultEntry; cursor: number } {585  const reader = new BinaryReader(data, start);586  const type = reader.u8();587  const id = bytesToUuid(reader.read(16));588  const flags = reader.u8();589  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;590  const createdSec = reader.u32();591  const updatedDelta = reader.u32();592  const deletedSec = reader.u32();593  const titleIdx = reader.u16();594595  const createdAt = createdSec * 1000;596  const modifiedAt = (createdSec + updatedDelta) * 1000;597  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;598  const title = lookupString(strings, titleIdx) ?? "";599600  const baseEntry: Partial<VaultEntry> = {601    id,602    title,603    createdAt,604    modifiedAt,605    ...(deletedAt !== undefined ? { deletedAt } : {}),606  };607608  if (type === ENTRY_TYPE_LOGIN) {609    const urlIdx = reader.u16();610    const usernameIdx = reader.u16();611    const password = reader.lpString(1);612    const tail: Partial<VaultEntry> = {};613614    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);615616    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);617    if (flags & F_HAS_TAGS) {618      const cnt = reader.u8();619      const tags: string[] = [];620      for (let i = 0; i < cnt; i++) {621        const v = lookupString(strings, reader.u16());622        if (v !== undefined) tags.push(v);623      }624      tail.tags = tags;625    }626    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());627    if (flags & F_HAS_CANARY) {628      const webhookUrl = lookupString(strings, reader.u16()) ?? "";629      const label = lookupString(strings, reader.u16());630      const canary: Canary = { webhookUrl };631      if (label !== undefined) canary.label = label;632      tail.canary = canary;633    }634    if (flags & F_HAS_PWCHANGED) {635      const pwDelta = reader.u32();636      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;637    }638    if (flags & F_HAS_BREACH) tail.breach = reader.u32();639    if (flags & F_HAS_ADDITIONAL_URLS) {640      const cnt = reader.u8();641      const urls: string[] = [];642      for (let i = 0; i < cnt; i++) {643        const v = lookupString(strings, reader.u16());644        if (v !== undefined) urls.push(v);645      }646      tail.additionalUrls = urls;647    }648649    return {650      entry: {651        ...(baseEntry as VaultEntry),652        type: "login",653        url: lookupString(strings, urlIdx),654        username: lookupString(strings, usernameIdx),655        password,656        ...tail,657      } as VaultEntry,658      cursor: reader.cursor,659    };660  }661662  if (type === ENTRY_TYPE_NOTE) {663    const tail: Partial<VaultEntry> = {};664    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());665    if (flags & F_HAS_TAGS) {666      const cnt = reader.u8();667      const tags: string[] = [];668      for (let i = 0; i < cnt; i++) {669        const v = lookupString(strings, reader.u16());670        if (v !== undefined) tags.push(v);671      }672      tail.tags = tags;673    }674    return {675      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,676      cursor: reader.cursor,677    };678  }679680  if (type === ENTRY_TYPE_CARD) {681    const cardholderIdx = reader.u16();682    const cardNumber = reader.lpString(1);683    const expMonth = reader.lpString(1);684    const expYear = reader.lpString(1);685    const cvc = reader.lpString(1);686    const nicknameIdx = reader.u16();687    const network = networkFromByte(reader.u8());688    const last4Idx = reader.u16();689    const tail: Partial<VaultEntry> = {};690    if (flags & F_HAS_TAGS) {691      const cnt = reader.u8();692      const tags: string[] = [];693      for (let i = 0; i < cnt; i++) {694        const v = lookupString(strings, reader.u16());695        if (v !== undefined) tags.push(v);696      }697      tail.tags = tags;698    }699    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());700701    return {702      entry: {703        ...(baseEntry as VaultEntry),704        type: "card",705        cardholderName: lookupString(strings, cardholderIdx),706        cardNumber,707        expMonth,708        expYear,709        cvc,710        cardNickname: lookupString(strings, nicknameIdx),711        network,712        last4: lookupString(strings, last4Idx),713        ...tail,714      } as VaultEntry,715      cursor: reader.cursor,716    };717  }718719  if (type === ENTRY_TYPE_PASSKEY) {720    const blobLen = reader.u32();721    const blob = reader.read(blobLen);722    const obj = decodeJsonSection<Record<string, unknown>>(blob);723    return {724      entry: {725        ...(baseEntry as VaultEntry),726        type: "passkey",727        ...obj,728      } as VaultEntry,729      cursor: reader.cursor,730    };731  }732733  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);734}735736function entryTypeByte(entry: VaultEntry): number {737  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys738  // as type:"login" entries with a nested passkey sub-object, so the type739  // field alone is not sufficient to detect them.740  if (entry.passkey) return ENTRY_TYPE_PASSKEY;741  switch (entry.type) {742    case "note":743      return ENTRY_TYPE_NOTE;744    case "card":745      return ENTRY_TYPE_CARD;746    case "passkey":747      return ENTRY_TYPE_PASSKEY;748    default:749      return ENTRY_TYPE_LOGIN;750  }751}752753function networkToByte(network: CardNetwork | undefined): number {754  if (!network) return 0xff;755  const idx = NETWORK_VALUES.indexOf(network);756  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");757}758759function networkFromByte(b: number): CardNetwork | undefined {760  if (b === 0xff) return undefined;761  return NETWORK_VALUES[b] ?? "unknown";762}763764// ---------------------------------------------------------------------------765// UUID packing — 16 raw bytes vs 36-character hyphenated string766// ---------------------------------------------------------------------------767768export function uuidToBytes(uuid: string): Uint8Array {769  const hex = uuid.replace(/-/g, "");770  if (hex.length !== 32) throw new Error("UUID_FORMAT");771  const out = new Uint8Array(16);772  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);773  return out;774}775776export function bytesToUuid(bytes: Uint8Array): string {777  if (bytes.length !== 16) throw new Error("UUID_LENGTH");778  const hex = Array.from(bytes)779    .map((b) => b.toString(16).padStart(2, "0"))780    .join("");781  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;782}783784// ---------------------------------------------------------------------------785// Tiny binary reader/writer786// ---------------------------------------------------------------------------787788class BinaryWriter {789  private chunks: Uint8Array[] = [];790  private len = 0;791792  get length(): number {793    return this.len;794  }795796  u8(v: number): void {797    const b = new Uint8Array(1);798    b[0] = v & 0xff;799    this.push(b);800  }801802  setU8(offset: number, v: number): void {803    // Only used to back-patch the flags byte; always lands inside chunks[1] or804    // earlier. Reconstruct by walking chunks until we hit the offset.805    let cursor = 0;806    for (const c of this.chunks) {807      if (offset < cursor + c.length) {808        c[offset - cursor] = v & 0xff;809        return;810      }811      cursor += c.length;812    }813    throw new Error("setU8 out of range");814  }815816  u16(v: number): void {817    const b = new Uint8Array(2);818    new DataView(b.buffer).setUint16(0, v & 0xffff, true);819    this.push(b);820  }821822  u32(v: number): void {823    const b = new Uint8Array(4);824    new DataView(b.buffer).setUint32(0, v >>> 0, true);825    this.push(b);826  }827828  bytes(b: Uint8Array): void {829    this.push(b);830  }831832  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */833  lpString(value: string, lenSize: 1 | 2): void {834    const enc = new TextEncoder().encode(value);835    if (lenSize === 1) {836      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");837      this.u8(enc.length);838    } else {839      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");840      this.u16(enc.length);841    }842    this.push(enc);843  }844845  toBytes(): Uint8Array {846    const out = new Uint8Array(this.len);847    let cursor = 0;848    for (const c of this.chunks) {849      out.set(c, cursor);850      cursor += c.length;851    }852    return out;853  }854855  private push(b: Uint8Array): void {856    this.chunks.push(b);857    this.len += b.length;858  }859}860861class BinaryReader {862  cursor: number;863  private view: DataView;864  constructor(private data: Uint8Array, start: number) {865    this.cursor = start;866    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);867  }868  u8(): number {869    return this.data[this.cursor++];870  }871  u16(): number {872    const v = this.view.getUint16(this.cursor, true);873    this.cursor += 2;874    return v;875  }876  u32(): number {877    const v = this.view.getUint32(this.cursor, true);878    this.cursor += 4;879    return v;880  }881  read(n: number): Uint8Array {882    const out = this.data.subarray(this.cursor, this.cursor + n);883    this.cursor += n;884    return out;885  }886  lpString(lenSize: 1 | 2): string {887    const len = lenSize === 1 ? this.u8() : this.u16();888    return new TextDecoder().decode(this.read(len));889  }890}891892// ---------------------------------------------------------------------------893// Crypto helpers894// ---------------------------------------------------------------------------895896async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {897  return argon2id({898    password: passphrase,899    salt,900    parallelism: ARGON2_PARALLELISM,901    iterations: ARGON2_ITERATIONS,902    memorySize: ARGON2_MEMORY_KIB,903    hashLength: 32,904    outputType: "binary",905  });906}907908async function aesGcmEncrypt(909  key: Uint8Array,910  nonce: Uint8Array,911  plaintext: Uint8Array912): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {913  const cryptoKey = await crypto.subtle.importKey(914    "raw",915    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,916    { name: "AES-GCM" },917    false,918    ["encrypt"]919  );920  const raw = await crypto.subtle.encrypt(921    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },922    cryptoKey,923    plaintext.buffer.slice(924      plaintext.byteOffset,925      plaintext.byteOffset + plaintext.byteLength926    ) as ArrayBuffer927  );928  const out = new Uint8Array(raw);929  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };930}931932async function aesGcmDecrypt(933  key: Uint8Array,934  nonce: Uint8Array,935  ciphertext: Uint8Array,936  tag: Uint8Array937): Promise<Uint8Array> {938  const cryptoKey = await crypto.subtle.importKey(939    "raw",940    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,941    { name: "AES-GCM" },942    false,943    ["decrypt"]944  );945  const combined = concat([ciphertext, tag]);946  const raw = await crypto.subtle.decrypt(947    {948      name: "AES-GCM",949      iv: nonce.buffer.slice(950        nonce.byteOffset,951        nonce.byteOffset + nonce.byteLength952      ) as ArrayBuffer,953      tagLength: 128,954    },955    cryptoKey,956    combined.buffer.slice(957      combined.byteOffset,958      combined.byteOffset + combined.byteLength959    ) as ArrayBuffer960  );961  return new Uint8Array(raw);962}963964async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {965  const hex = await blake3(data);966  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.967  const out = new Uint8Array(CHECKSUM_LEN);968  for (let i = 0; i < CHECKSUM_LEN; i++) {969    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);970  }971  return out;972}973974function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {975  if (a.length !== b.length) return false;976  let diff = 0;977  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];978  return diff === 0;979}980981function concat(parts: Uint8Array[]): Uint8Array {982  let total = 0;983  for (const p of parts) total += p.length;984  const out = new Uint8Array(total);985  let cursor = 0;986  for (const p of parts) {987    out.set(p, cursor);988    cursor += p.length;989  }990  return out;991}