38a1ce3728e4

add peachPayload.ts — section-based binary format with shared string table, delta timestamps, raw UUID bytes, AES-256-GCM envelope under Argon2id

time
2026-04-27 15:59 UTC · 6 months ago
machine
koh2
parent
1ad3a3572756
2 files src/crypto/peachPayload.test.ts+248 −0src/crypto/peachPayload.ts+902 −0
src/crypto/peachPayload.test.ts+248 −0
@@ -0,0 +1,248 @@1+import { describe, expect, test } from "vitest";2+3+import {4+  bytesToUuid,5+  buildPayload,6+  parsePayload,7+  uuidToBytes,8+} from "./peachPayload";9+import type { Canary, VaultEntry } from "../shared/types";10+import type { VaultSettings } from "../background/vault";11+12+const PASS = "correct horse battery staple";13+14+function loginEntry(over: Partial<VaultEntry> = {}): VaultEntry {15+  return {16+    id: crypto.randomUUID(),17+    type: "login",18+    title: "github",19+    username: "[email protected]",20+    password: "p4ssw0rd!",21+    url: "https://github.com",22+    createdAt: 1700000000000,23+    modifiedAt: 1700001000000,24+    ...over,25+  };26+}27+28+function noteEntry(over: Partial<VaultEntry> = {}): VaultEntry {29+  return {30+    id: crypto.randomUUID(),31+    type: "note",32+    title: "private note",33+    notes: "remember the milk",34+    createdAt: 1700000000000,35+    modifiedAt: 1700000000000,36+    ...over,37+  };38+}39+40+function cardEntry(over: Partial<VaultEntry> = {}): VaultEntry {41+  return {42+    id: crypto.randomUUID(),43+    type: "card",44+    title: "personal visa",45+    cardholderName: "June Asha",46+    cardNumber: "4111111111111111",47+    expMonth: "08",48+    expYear: "2029",49+    cvc: "123",50+    network: "visa",51+    last4: "1111",52+    createdAt: 1700000000000,53+    modifiedAt: 1700000000000,54+    ...over,55+  };56+}57+58+function passkeyEntry(over: Partial<VaultEntry> = {}): VaultEntry {59+  return {60+    id: crypto.randomUUID(),61+    type: "passkey",62+    title: "github passkey",63+    rpId: "github.com",64+    rpName: "GitHub",65+    credentialId: "abc123",66+    publicKeyCose: "a401020326...",67+    privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },68+    userHandle: "user-handle",69+    userName: "june",70+    userDisplayName: "June",71+    algorithm: -7,72+    signCount: 5,73+    aaguid: "AAAAAAAAAAAAAAAAAAAAAA",74+    backupEligible: true,75+    backupState: false,76+    createdAt: 1700000000000,77+    modifiedAt: 1700000000000,78+    ...over,79+  };80+}81+82+describe("peachPayload", () => {83+  test("UUID round-trip preserves the hyphenated string", () => {84+    const id = "123e4567-e89b-42d3-a456-426614174000";85+    expect(bytesToUuid(uuidToBytes(id))).toBe(id);86+  });87+88+  test("login entry round-trips all fields", async () => {89+    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };90+    const entry = loginEntry({91+      totp: "JBSWY3DPEHPK3PXP",92+      tags: ["personal", "dev"],93+      notes: "second factor on yubikey",94+      canary,95+      breach: 4,96+      passwordChangedAt: 1700000500000,97+      additionalUrls: ["https://gist.github.com"],98+    });99+100+    const env = await buildPayload({101+      entries: [entry],102+      settings: {},103+      passphrase: PASS,104+    });105+    const out = await parsePayload(env, PASS);106+    expect(out.entries).toHaveLength(1);107+    const got = out.entries[0];108+    expect(got.id).toBe(entry.id);109+    expect(got.title).toBe(entry.title);110+    expect(got.url).toBe(entry.url);111+    expect(got.username).toBe(entry.username);112+    expect(got.password).toBe(entry.password);113+    expect(got.totp).toBe(entry.totp);114+    expect(got.tags).toEqual(entry.tags);115+    expect(got.notes).toBe(entry.notes);116+    expect(got.canary).toEqual(canary);117+    expect(got.breach).toBe(4);118+    expect(got.additionalUrls).toEqual(entry.additionalUrls);119+    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);120+    expect(got.createdAt).toBe(entry.createdAt);121+    expect(got.modifiedAt).toBe(entry.modifiedAt);122+  });123+124+  test("note, card, and passkey entries round-trip", async () => {125+    const note = noteEntry({ tags: ["secrets"] });126+    const card = cardEntry({ cardNickname: "daily driver" });127+    const passkey = passkeyEntry();128+129+    const env = await buildPayload({130+      entries: [note, card, passkey],131+      settings: {},132+      passphrase: PASS,133+    });134+    const out = await parsePayload(env, PASS);135+    expect(out.entries).toHaveLength(3);136+137+    const gotNote = out.entries.find((e) => e.id === note.id)!;138+    expect(gotNote.type).toBe("note");139+    expect(gotNote.notes).toBe(note.notes);140+    expect(gotNote.tags).toEqual(["secrets"]);141+142+    const gotCard = out.entries.find((e) => e.id === card.id)!;143+    expect(gotCard.type).toBe("card");144+    expect(gotCard.cardNumber).toBe(card.cardNumber);145+    expect(gotCard.expMonth).toBe(card.expMonth);146+    expect(gotCard.expYear).toBe(card.expYear);147+    expect(gotCard.cvc).toBe(card.cvc);148+    expect(gotCard.network).toBe("visa");149+    expect(gotCard.last4).toBe("1111");150+    expect(gotCard.cardNickname).toBe("daily driver");151+    expect(gotCard.cardholderName).toBe(card.cardholderName);152+153+    const gotPk = out.entries.find((e) => e.id === passkey.id)!;154+    expect(gotPk.type).toBe("passkey");155+    expect(gotPk.rpId).toBe("github.com");156+    expect(gotPk.privateKeyJwk).toEqual(passkey.privateKeyJwk);157+    expect(gotPk.algorithm).toBe(-7);158+    expect(gotPk.signCount).toBe(5);159+    expect(gotPk.backupEligible).toBe(true);160+    expect(gotPk.backupState).toBe(false);161+  });162+163+  test("string table deduplicates shared domains", async () => {164+    const entries = Array.from({ length: 20 }, (_, i) =>165+      loginEntry({166+        title: `acct-${i}`,167+        username: "[email protected]",168+        url: "https://github.com",169+        password: `p-${i}`,170+      })171+    );172+    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });173+    const out = await parsePayload(env, PASS);174+    expect(out.entries).toHaveLength(20);175+    for (const e of out.entries) {176+      expect(e.url).toBe("https://github.com");177+      expect(e.username).toBe("[email protected]");178+    }179+  });180+181+  test("settings round-trip", async () => {182+    const settings: VaultSettings = {183+      accentColor: "rose",184+      theme: "light",185+      autoLockMinutes: 15,186+      lockOnClose: true,187+    };188+    const env = await buildPayload({ entries: [], settings, passphrase: PASS });189+    const out = await parsePayload(env, PASS);190+    expect(out.settings).toEqual(settings);191+  });192+193+  test("license round-trip and activation timestamp", async () => {194+    const env = await buildPayload({195+      entries: [],196+      settings: {},197+      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },198+      passphrase: PASS,199+    });200+    const out = await parsePayload(env, PASS);201+    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });202+  });203+204+  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {205+    const env = await buildPayload({206+      entries: [loginEntry()],207+      settings: {},208+      passphrase: PASS,209+    });210+    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");211+  });212+213+  test("tampered ciphertext fails AEAD", async () => {214+    const env = await buildPayload({215+      entries: [loginEntry()],216+      settings: {},217+      passphrase: PASS,218+    });219+    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)220+    await expect(parsePayload(env, PASS)).rejects.toThrow();221+  });222+223+  test("trashed entries preserve deletedAt", async () => {224+    const entry = loginEntry({ deletedAt: 1700002000000 });225+    const env = await buildPayload({226+      entries: [entry],227+      settings: {},228+      passphrase: PASS,229+    });230+    const out = await parsePayload(env, PASS);231+    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);232+  });233+234+  test("100 entries fit comfortably in a small envelope", async () => {235+    const entries = Array.from({ length: 100 }, (_, i) =>236+      loginEntry({237+        title: `account-${i}`,238+        username: `user${i % 10}@example.com`,239+        url: "https://example.com",240+        password: "qwerty-12345-zxcvbn",241+      })242+    );243+    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });244+    expect(env.length).toBeLessThan(8000);245+    const out = await parsePayload(env, PASS);246+    expect(out.entries).toHaveLength(100);247+  });248+});

view file

src/crypto/peachPayload.ts+902 −0
@@ -0,0 +1,902 @@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+ */31+32+import { argon2id, blake3 } from "hash-wasm";33+34+import { compressZstd, decompressZstd } from "./zstd";35+import type {36+  Canary,37+  CardNetwork,38+  ManualSummonRecord,39+  PeachLicenseExport,40+  VaultEntry,41+} from "../shared/types";42+import type { VaultSettings } from "../background/vault";43+44+// ---------------------------------------------------------------------------45+// Header / section constants46+// ---------------------------------------------------------------------------47+48+export const MAGIC = 0x50434853; // "PCHS"49+export const VERSION = 0x01;50+export const HEADER_LEN = 8;51+export const CHECKSUM_LEN = 4;52+53+export const SALT_LEN = 16;54+export const NONCE_LEN = 12;55+export const TAG_LEN = 16;56+57+const ARGON2_MEMORY_KIB = 65536; // 64 MiB58+const ARGON2_ITERATIONS = 3;59+const ARGON2_PARALLELISM = 4;60+61+const SECTION_ENTRIES = 0x01;62+const SECTION_SETTINGS = 0x02;63+const SECTION_LICENSE = 0x03;64+const SECTION_RECALL = 0x04;65+const SECTION_DECLINED_CARDS = 0x05;66+67+const ENTRY_TYPE_LOGIN = 0x01;68+const ENTRY_TYPE_NOTE = 0x02;69+const ENTRY_TYPE_CARD = 0x03;70+const ENTRY_TYPE_PASSKEY = 0x04;71+72+// Login entry flag bits73+const F_HAS_TOTP = 1 << 0;74+const F_HAS_TAGS = 1 << 1;75+const F_HAS_NOTES = 1 << 2;76+const F_HAS_CANARY = 1 << 3;77+const F_HAS_PWCHANGED = 1 << 4;78+const F_HAS_BREACH = 1 << 5;79+const F_HAS_ADDITIONAL_URLS = 1 << 6;80+const F_HAS_DELETED = 1 << 7;81+82+const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];83+84+// ---------------------------------------------------------------------------85+// Public API86+// ---------------------------------------------------------------------------87+88+export interface BuildPayloadInput {89+  entries: VaultEntry[];90+  settings: VaultSettings;91+  license?: PeachLicenseExport;92+  /** Optional declined card fingerprints (subset of settings — split out per spec). */93+  declinedCardFingerprints?: string[];94+  /** Optional recall map (subset of settings — split out per spec). */95+  manualSummonDomains?: Record<string, ManualSummonRecord>;96+  passphrase: string;97+}98+99+export interface ParsePayloadOutput {100+  entries: VaultEntry[];101+  settings: VaultSettings;102+  license?: PeachLicenseExport;103+  declinedCardFingerprints?: string[];104+  manualSummonDomains?: Record<string, ManualSummonRecord>;105+}106+107+/**108+ * Build the encrypted envelope: assemble sections → BLAKE3-checksum →109+ * AES-256-GCM encrypt under an Argon2id-derived key. Returns110+ * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into111+ * `encodePeachScript` or the QR matrix encoder.112+ */113+export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {114+  const sections: Uint8Array[] = [];115+116+  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries)));117+  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));118+  if (input.license) {119+    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));120+  }121+  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {122+    sections.push(123+      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))124+    );125+  }126+  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {127+    sections.push(128+      await buildSection(129+        SECTION_DECLINED_CARDS,130+        encodeJsonSection(input.declinedCardFingerprints)131+      )132+    );133+  }134+135+  const header = new Uint8Array(HEADER_LEN);136+  const headerView = new DataView(header.buffer);137+  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump138+  header[4] = VERSION;139+  header[5] = sections.length;140+  // bytes 6,7: reserved (zero)141+142+  const body = concat([header, ...sections]);143+  const checksum = await blake3Truncated(body);144+  const inner = concat([body, checksum]);145+146+  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));147+  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));148+  const key = await deriveKey(input.passphrase, salt);149+  try {150+    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, inner);151+    return concat([salt, nonce, ciphertext, tag]);152+  } finally {153+    key.fill(0);154+  }155+}156+157+/**158+ * Parse the encrypted envelope back to a typed payload. Throws159+ * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a160+ * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or161+ * `CHECKSUM_FAILED` on a corrupt inner payload.162+ */163+export async function parsePayload(164+  envelope: Uint8Array,165+  passphrase: string166+): Promise<ParsePayloadOutput> {167+  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {168+    throw new Error("PAYLOAD_TOO_SHORT");169+  }170+171+  const salt = envelope.subarray(0, SALT_LEN);172+  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);173+  const tag = envelope.subarray(envelope.length - TAG_LEN);174+  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);175+176+  const key = await deriveKey(passphrase, salt);177+  let inner: Uint8Array;178+  try {179+    inner = await aesGcmDecrypt(key, nonce, ciphertext, tag);180+  } catch {181+    throw new Error("INVALID_PASSPHRASE");182+  } finally {183+    key.fill(0);184+  }185+186+  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");187+188+  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);189+  const stored = inner.subarray(inner.length - CHECKSUM_LEN);190+  const computed = await blake3Truncated(body);191+  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");192+193+  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);194+  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");195+  const version = body[4];196+  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);197+  const sectionCount = body[5];198+199+  let cursor = HEADER_LEN;200+  const out: ParsePayloadOutput = {201+    entries: [],202+    settings: {},203+  };204+  for (let i = 0; i < sectionCount; i++) {205+    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");206+    const type = body[cursor];207+    const length = view.getUint32(cursor + 1, true);208+    const clength = view.getUint32(cursor + 5, true);209+    const dataStart = cursor + 9;210+    const dataEnd = dataStart + clength;211+    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");212+    const compressed = body.subarray(dataStart, dataEnd);213+    const data = await decompressZstd(compressed);214+    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");215+216+    switch (type) {217+      case SECTION_ENTRIES:218+        out.entries = decodeEntriesSection(data);219+        break;220+      case SECTION_SETTINGS:221+        out.settings = decodeJsonSection<VaultSettings>(data);222+        break;223+      case SECTION_LICENSE:224+        out.license = decodeJsonSection<PeachLicenseExport>(data);225+        break;226+      case SECTION_RECALL:227+        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);228+        break;229+      case SECTION_DECLINED_CARDS:230+        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);231+        break;232+      default:233+        // Unknown section — skip rather than fail, so future-introduced234+        // sections do not break older clients reading newer payloads.235+        break;236+    }237+238+    cursor = dataEnd;239+  }240+241+  return out;242+}243+244+// ---------------------------------------------------------------------------245+// Section assembly246+// ---------------------------------------------------------------------------247+248+async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {249+  const compressed = await compressZstd(raw, 19);250+  const out = new Uint8Array(9 + compressed.length);251+  const view = new DataView(out.buffer);252+  out[0] = type;253+  view.setUint32(1, raw.length, true);254+  view.setUint32(5, compressed.length, true);255+  out.set(compressed, 9);256+  return out;257+}258+259+function encodeJsonSection(value: unknown): Uint8Array {260+  return new TextEncoder().encode(JSON.stringify(value));261+}262+263+function decodeJsonSection<T>(data: Uint8Array): T {264+  return JSON.parse(new TextDecoder().decode(data)) as T;265+}266+267+// ---------------------------------------------------------------------------268+// Entries section: shared string table + per-type packed bodies269+// ---------------------------------------------------------------------------270+271+class StringTableBuilder {272+  private map = new Map<string, number>();273+  private list: string[] = [];274+275+  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */276+  intern(value: string | undefined): number {277+    if (!value) return 0;278+    const existing = this.map.get(value);279+    if (existing !== undefined) return existing;280+    const idx = this.list.length + 1; // 1-indexed; 0 = absent281+    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");282+    this.map.set(value, idx);283+    this.list.push(value);284+    return idx;285+  }286+287+  serialize(): Uint8Array {288+    const enc = new TextEncoder();289+    const encoded = this.list.map((s) => enc.encode(s));290+    let total = 2; // u16 count291+    for (const e of encoded) total += 2 + e.length;292+    const out = new Uint8Array(total);293+    const view = new DataView(out.buffer);294+    let cursor = 0;295+    view.setUint16(cursor, encoded.length, true);296+    cursor += 2;297+    for (const e of encoded) {298+      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");299+      view.setUint16(cursor, e.length, true);300+      cursor += 2;301+      out.set(e, cursor);302+      cursor += e.length;303+    }304+    return out;305+  }306+}307+308+function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {309+  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);310+  const count = view.getUint16(cursor, true);311+  cursor += 2;312+  const strings: string[] = [];313+  const dec = new TextDecoder();314+  for (let i = 0; i < count; i++) {315+    const len = view.getUint16(cursor, true);316+    cursor += 2;317+    strings.push(dec.decode(data.subarray(cursor, cursor + len)));318+    cursor += len;319+  }320+  return { strings, cursor };321+}322+323+function lookupString(strings: string[], idx: number): string | undefined {324+  if (idx === 0) return undefined;325+  return strings[idx - 1];326+}327+328+function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {329+  const table = new StringTableBuilder();330+  const bodies: Uint8Array[] = [];331+332+  // Pre-intern title for every entry so the table is built consistently.333+  for (const entry of entries) table.intern(entry.title);334+335+  for (const entry of entries) {336+    bodies.push(encodeEntry(entry, table));337+  }338+339+  const tableBytes = table.serialize();340+341+  // [u32 count][string_table][entries...]342+  let bodiesLen = 0;343+  for (const b of bodies) bodiesLen += b.length;344+  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);345+  const view = new DataView(out.buffer);346+  view.setUint32(0, entries.length, true);347+  out.set(tableBytes, 4);348+  let cursor = 4 + tableBytes.length;349+  for (const b of bodies) {350+    out.set(b, cursor);351+    cursor += b.length;352+  }353+  return out;354+}355+356+function decodeEntriesSection(data: Uint8Array): VaultEntry[] {357+  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);358+  const count = view.getUint32(0, true);359+  const { strings, cursor: afterTable } = readStringTable(data, 4);360+361+  const out: VaultEntry[] = [];362+  let cursor = afterTable;363+  for (let i = 0; i < count; i++) {364+    const { entry, cursor: next } = decodeEntry(data, cursor, strings);365+    out.push(entry);366+    cursor = next;367+  }368+  return out;369+}370+371+// ---------------------------------------------------------------------------372+// Per-entry packing373+// ---------------------------------------------------------------------------374+375+function encodeEntry(entry: VaultEntry, table: StringTableBuilder): Uint8Array {376+  const writer = new BinaryWriter();377+  const type = entryTypeByte(entry);378+  writer.u8(type);379+  writer.bytes(uuidToBytes(entry.id));380+381+  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx382+  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);383+  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);384+  const updatedDelta = Math.max(0, updatedSec - createdSec);385+  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;386+387+  // flags placeholder (we'll overwrite at the end)388+  const flagOffset = writer.length;389+  writer.u8(0);390+391+  writer.u32(createdSec);392+  writer.u32(updatedDelta);393+  writer.u32(deletedSec);394+  writer.u16(table.intern(entry.title));395+396+  let flags = 0;397+  if (entry.deletedAt) flags |= F_HAS_DELETED;398+399+  if (type === ENTRY_TYPE_LOGIN) {400+    writer.u16(table.intern(entry.url));401+    writer.u16(table.intern(entry.username));402+    writer.lpString(entry.password ?? "", 1);403+404+    if (entry.totp) {405+      flags |= F_HAS_TOTP;406+      writer.lpString(entry.totp, 1);407+    }408+    if (entry.tags && entry.tags.length > 0) {409+      flags |= F_HAS_TAGS;410+      writer.u8(Math.min(entry.tags.length, 0xff));411+      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {412+        writer.u16(table.intern(entry.tags[i]));413+      }414+    }415+    if (entry.notes) {416+      flags |= F_HAS_NOTES;417+      writer.u16(table.intern(entry.notes));418+    }419+    if (entry.canary) {420+      flags |= F_HAS_CANARY;421+      writer.u16(table.intern(entry.canary.webhookUrl));422+      writer.u16(table.intern(entry.canary.label));423+    }424+    if (entry.passwordChangedAt) {425+      flags |= F_HAS_PWCHANGED;426+      const pwSec = Math.floor(entry.passwordChangedAt / 1000);427+      writer.u32(Math.max(0, pwSec - createdSec));428+    }429+    if (typeof entry.breach === "number" && entry.breach > 0) {430+      flags |= F_HAS_BREACH;431+      writer.u32(entry.breach);432+    }433+    if (entry.additionalUrls && entry.additionalUrls.length > 0) {434+      flags |= F_HAS_ADDITIONAL_URLS;435+      writer.u8(Math.min(entry.additionalUrls.length, 0xff));436+      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {437+        writer.u16(table.intern(entry.additionalUrls[i]));438+      }439+    }440+  } else if (type === ENTRY_TYPE_NOTE) {441+    if (entry.notes) {442+      flags |= F_HAS_NOTES;443+      writer.u16(table.intern(entry.notes));444+    }445+    if (entry.tags && entry.tags.length > 0) {446+      flags |= F_HAS_TAGS;447+      writer.u8(Math.min(entry.tags.length, 0xff));448+      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {449+        writer.u16(table.intern(entry.tags[i]));450+      }451+    }452+  } else if (type === ENTRY_TYPE_CARD) {453+    writer.u16(table.intern(entry.cardholderName));454+    writer.lpString(entry.cardNumber ?? "", 1);455+    writer.lpString(entry.expMonth ?? "", 1);456+    writer.lpString(entry.expYear ?? "", 1);457+    writer.lpString(entry.cvc ?? "", 1);458+    writer.u16(table.intern(entry.cardNickname));459+    writer.u8(networkToByte(entry.network));460+    writer.u16(table.intern(entry.last4));461+    if (entry.tags && entry.tags.length > 0) {462+      flags |= F_HAS_TAGS;463+      writer.u8(Math.min(entry.tags.length, 0xff));464+      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {465+        writer.u16(table.intern(entry.tags[i]));466+      }467+    }468+    if (entry.notes) {469+      flags |= F_HAS_NOTES;470+      writer.u16(table.intern(entry.notes));471+    }472+  } else if (type === ENTRY_TYPE_PASSKEY) {473+    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.474+    const blob = encodeJsonSection({475+      rpId: entry.rpId,476+      rpName: entry.rpName,477+      credentialId: entry.credentialId,478+      publicKeyCose: entry.publicKeyCose,479+      privateKeyJwk: entry.privateKeyJwk,480+      userHandle: entry.userHandle,481+      userName: entry.userName,482+      userDisplayName: entry.userDisplayName,483+      algorithm: entry.algorithm,484+      signCount: entry.signCount,485+      aaguid: entry.aaguid,486+      backupEligible: entry.backupEligible,487+      backupState: entry.backupState,488+      lastUsedAt: entry.lastUsedAt,489+    });490+    writer.u32(blob.length);491+    writer.bytes(blob);492+  }493+494+  writer.setU8(flagOffset, flags);495+  return writer.toBytes();496+}497+498+function decodeEntry(499+  data: Uint8Array,500+  start: number,501+  strings: string[]502+): { entry: VaultEntry; cursor: number } {503+  const reader = new BinaryReader(data, start);504+  const type = reader.u8();505+  const id = bytesToUuid(reader.read(16));506+  const flags = reader.u8();507+  const createdSec = reader.u32();508+  const updatedDelta = reader.u32();509+  const deletedSec = reader.u32();510+  const titleIdx = reader.u16();511+512+  const createdAt = createdSec * 1000;513+  const modifiedAt = (createdSec + updatedDelta) * 1000;514+  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;515+  const title = lookupString(strings, titleIdx) ?? "";516+517+  const baseEntry: Partial<VaultEntry> = {518+    id,519+    title,520+    createdAt,521+    modifiedAt,522+    ...(deletedAt !== undefined ? { deletedAt } : {}),523+  };524+525+  if (type === ENTRY_TYPE_LOGIN) {526+    const urlIdx = reader.u16();527+    const usernameIdx = reader.u16();528+    const password = reader.lpString(1);529+    const tail: Partial<VaultEntry> = {};530+531+    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);532+    if (flags & F_HAS_TAGS) {533+      const cnt = reader.u8();534+      const tags: string[] = [];535+      for (let i = 0; i < cnt; i++) {536+        const v = lookupString(strings, reader.u16());537+        if (v !== undefined) tags.push(v);538+      }539+      tail.tags = tags;540+    }541+    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());542+    if (flags & F_HAS_CANARY) {543+      const webhookUrl = lookupString(strings, reader.u16()) ?? "";544+      const label = lookupString(strings, reader.u16());545+      const canary: Canary = { webhookUrl };546+      if (label !== undefined) canary.label = label;547+      tail.canary = canary;548+    }549+    if (flags & F_HAS_PWCHANGED) {550+      const pwDelta = reader.u32();551+      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;552+    }553+    if (flags & F_HAS_BREACH) tail.breach = reader.u32();554+    if (flags & F_HAS_ADDITIONAL_URLS) {555+      const cnt = reader.u8();556+      const urls: string[] = [];557+      for (let i = 0; i < cnt; i++) {558+        const v = lookupString(strings, reader.u16());559+        if (v !== undefined) urls.push(v);560+      }561+      tail.additionalUrls = urls;562+    }563+564+    return {565+      entry: {566+        ...(baseEntry as VaultEntry),567+        type: "login",568+        url: lookupString(strings, urlIdx),569+        username: lookupString(strings, usernameIdx),570+        password,571+        ...tail,572+      } as VaultEntry,573+      cursor: reader.cursor,574+    };575+  }576+577+  if (type === ENTRY_TYPE_NOTE) {578+    const tail: Partial<VaultEntry> = {};579+    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());580+    if (flags & F_HAS_TAGS) {581+      const cnt = reader.u8();582+      const tags: string[] = [];583+      for (let i = 0; i < cnt; i++) {584+        const v = lookupString(strings, reader.u16());585+        if (v !== undefined) tags.push(v);586+      }587+      tail.tags = tags;588+    }589+    return {590+      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,591+      cursor: reader.cursor,592+    };593+  }594+595+  if (type === ENTRY_TYPE_CARD) {596+    const cardholderIdx = reader.u16();597+    const cardNumber = reader.lpString(1);598+    const expMonth = reader.lpString(1);599+    const expYear = reader.lpString(1);600+    const cvc = reader.lpString(1);601+    const nicknameIdx = reader.u16();602+    const network = networkFromByte(reader.u8());603+    const last4Idx = reader.u16();604+    const tail: Partial<VaultEntry> = {};605+    if (flags & F_HAS_TAGS) {606+      const cnt = reader.u8();607+      const tags: string[] = [];608+      for (let i = 0; i < cnt; i++) {609+        const v = lookupString(strings, reader.u16());610+        if (v !== undefined) tags.push(v);611+      }612+      tail.tags = tags;613+    }614+    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());615+616+    return {617+      entry: {618+        ...(baseEntry as VaultEntry),619+        type: "card",620+        cardholderName: lookupString(strings, cardholderIdx),621+        cardNumber,622+        expMonth,623+        expYear,624+        cvc,625+        cardNickname: lookupString(strings, nicknameIdx),626+        network,627+        last4: lookupString(strings, last4Idx),628+        ...tail,629+      } as VaultEntry,630+      cursor: reader.cursor,631+    };632+  }633+634+  if (type === ENTRY_TYPE_PASSKEY) {635+    const blobLen = reader.u32();636+    const blob = reader.read(blobLen);637+    const obj = decodeJsonSection<Record<string, unknown>>(blob);638+    return {639+      entry: {640+        ...(baseEntry as VaultEntry),641+        type: "passkey",642+        ...obj,643+      } as VaultEntry,644+      cursor: reader.cursor,645+    };646+  }647+648+  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);649+}650+651+function entryTypeByte(entry: VaultEntry): number {652+  switch (entry.type) {653+    case "note":654+      return ENTRY_TYPE_NOTE;655+    case "card":656+      return ENTRY_TYPE_CARD;657+    case "passkey":658+      return ENTRY_TYPE_PASSKEY;659+    default:660+      return ENTRY_TYPE_LOGIN;661+  }662+}663+664+function networkToByte(network: CardNetwork | undefined): number {665+  if (!network) return 0xff;666+  const idx = NETWORK_VALUES.indexOf(network);667+  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");668+}669+670+function networkFromByte(b: number): CardNetwork | undefined {671+  if (b === 0xff) return undefined;672+  return NETWORK_VALUES[b] ?? "unknown";673+}674+675+// ---------------------------------------------------------------------------676+// UUID packing — 16 raw bytes vs 36-character hyphenated string677+// ---------------------------------------------------------------------------678+679+export function uuidToBytes(uuid: string): Uint8Array {680+  const hex = uuid.replace(/-/g, "");681+  if (hex.length !== 32) throw new Error("UUID_FORMAT");682+  const out = new Uint8Array(16);683+  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);684+  return out;685+}686+687+export function bytesToUuid(bytes: Uint8Array): string {688+  if (bytes.length !== 16) throw new Error("UUID_LENGTH");689+  const hex = Array.from(bytes)690+    .map((b) => b.toString(16).padStart(2, "0"))691+    .join("");692+  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;693+}694+695+// ---------------------------------------------------------------------------696+// Tiny binary reader/writer697+// ---------------------------------------------------------------------------698+699+class BinaryWriter {700+  private chunks: Uint8Array[] = [];701+  private len = 0;702+703+  get length(): number {704+    return this.len;705+  }706+707+  u8(v: number): void {708+    const b = new Uint8Array(1);709+    b[0] = v & 0xff;710+    this.push(b);711+  }712+713+  setU8(offset: number, v: number): void {714+    // Only used to back-patch the flags byte; always lands inside chunks[1] or715+    // earlier. Reconstruct by walking chunks until we hit the offset.716+    let cursor = 0;717+    for (const c of this.chunks) {718+      if (offset < cursor + c.length) {719+        c[offset - cursor] = v & 0xff;720+        return;721+      }722+      cursor += c.length;723+    }724+    throw new Error("setU8 out of range");725+  }726+727+  u16(v: number): void {728+    const b = new Uint8Array(2);729+    new DataView(b.buffer).setUint16(0, v & 0xffff, true);730+    this.push(b);731+  }732+733+  u32(v: number): void {734+    const b = new Uint8Array(4);735+    new DataView(b.buffer).setUint32(0, v >>> 0, true);736+    this.push(b);737+  }738+739+  bytes(b: Uint8Array): void {740+    this.push(b);741+  }742+743+  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */744+  lpString(value: string, lenSize: 1 | 2): void {745+    const enc = new TextEncoder().encode(value);746+    if (lenSize === 1) {747+      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");748+      this.u8(enc.length);749+    } else {750+      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");751+      this.u16(enc.length);752+    }753+    this.push(enc);754+  }755+756+  toBytes(): Uint8Array {757+    const out = new Uint8Array(this.len);758+    let cursor = 0;759+    for (const c of this.chunks) {760+      out.set(c, cursor);761+      cursor += c.length;762+    }763+    return out;764+  }765+766+  private push(b: Uint8Array): void {767+    this.chunks.push(b);768+    this.len += b.length;769+  }770+}771+772+class BinaryReader {773+  cursor: number;774+  private view: DataView;775+  constructor(private data: Uint8Array, start: number) {776+    this.cursor = start;777+    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);778+  }779+  u8(): number {780+    return this.data[this.cursor++];781+  }782+  u16(): number {783+    const v = this.view.getUint16(this.cursor, true);784+    this.cursor += 2;785+    return v;786+  }787+  u32(): number {788+    const v = this.view.getUint32(this.cursor, true);789+    this.cursor += 4;790+    return v;791+  }792+  read(n: number): Uint8Array {793+    const out = this.data.subarray(this.cursor, this.cursor + n);794+    this.cursor += n;795+    return out;796+  }797+  lpString(lenSize: 1 | 2): string {798+    const len = lenSize === 1 ? this.u8() : this.u16();799+    return new TextDecoder().decode(this.read(len));800+  }801+}802+803+// ---------------------------------------------------------------------------804+// Crypto helpers805+// ---------------------------------------------------------------------------806+807+async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {808+  return argon2id({809+    password: passphrase,810+    salt,811+    parallelism: ARGON2_PARALLELISM,812+    iterations: ARGON2_ITERATIONS,813+    memorySize: ARGON2_MEMORY_KIB,814+    hashLength: 32,815+    outputType: "binary",816+  });817+}818+819+async function aesGcmEncrypt(820+  key: Uint8Array,821+  nonce: Uint8Array,822+  plaintext: Uint8Array823+): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {824+  const cryptoKey = await crypto.subtle.importKey(825+    "raw",826+    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,827+    { name: "AES-GCM" },828+    false,829+    ["encrypt"]830+  );831+  const raw = await crypto.subtle.encrypt(832+    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },833+    cryptoKey,834+    plaintext.buffer.slice(835+      plaintext.byteOffset,836+      plaintext.byteOffset + plaintext.byteLength837+    ) as ArrayBuffer838+  );839+  const out = new Uint8Array(raw);840+  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };841+}842+843+async function aesGcmDecrypt(844+  key: Uint8Array,845+  nonce: Uint8Array,846+  ciphertext: Uint8Array,847+  tag: Uint8Array848+): Promise<Uint8Array> {849+  const cryptoKey = await crypto.subtle.importKey(850+    "raw",851+    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,852+    { name: "AES-GCM" },853+    false,854+    ["decrypt"]855+  );856+  const combined = concat([ciphertext, tag]);857+  const raw = await crypto.subtle.decrypt(858+    {859+      name: "AES-GCM",860+      iv: nonce.buffer.slice(861+        nonce.byteOffset,862+        nonce.byteOffset + nonce.byteLength863+      ) as ArrayBuffer,864+      tagLength: 128,865+    },866+    cryptoKey,867+    combined.buffer.slice(868+      combined.byteOffset,869+      combined.byteOffset + combined.byteLength870+    ) as ArrayBuffer871+  );872+  return new Uint8Array(raw);873+}874+875+async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {876+  const hex = await blake3(data);877+  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.878+  const out = new Uint8Array(CHECKSUM_LEN);879+  for (let i = 0; i < CHECKSUM_LEN; i++) {880+    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);881+  }882+  return out;883+}884+885+function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {886+  if (a.length !== b.length) return false;887+  let diff = 0;888+  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];889+  return diff === 0;890+}891+892+function concat(parts: Uint8Array[]): Uint8Array {893+  let total = 0;894+  for (const p of parts) total += p.length;895+  const out = new Uint8Array(total);896+  let cursor = 0;897+  for (const p of parts) {898+    out.set(p, cursor);899+    cursor += p.length;900+  }901+  return out;902+}

view file