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
↓ peach-browser-38a1ce37.tar.gzatlas at this save
files changed2 files
src/crypto/peachPayload.test.ts+248 −0src/crypto/peachPayload.ts+902 −0src/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+});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+}