peachPayload.ts

29.5 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;5657const ARGON2_MEMORY_KIB = 65536; // 64 MiB58const ARGON2_ITERATIONS = 3;59const ARGON2_PARALLELISM = 4;6061const SECTION_ENTRIES = 0x01;62const SECTION_SETTINGS = 0x02;63const SECTION_LICENSE = 0x03;64const SECTION_RECALL = 0x04;65const SECTION_DECLINED_CARDS = 0x05;6667const ENTRY_TYPE_LOGIN = 0x01;68const ENTRY_TYPE_NOTE = 0x02;69const ENTRY_TYPE_CARD = 0x03;70const ENTRY_TYPE_PASSKEY = 0x04;7172// Login entry flag bits73const F_HAS_TOTP = 1 << 0;74const F_HAS_TAGS = 1 << 1;75const F_HAS_NOTES = 1 << 2;76const F_HAS_CANARY = 1 << 3;77const F_HAS_PWCHANGED = 1 << 4;78const F_HAS_BREACH = 1 << 5;79const F_HAS_ADDITIONAL_URLS = 1 << 6;80const F_HAS_DELETED = 1 << 7;8182const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];8384// ---------------------------------------------------------------------------85// Public API86// ---------------------------------------------------------------------------8788export 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}9899export interface ParsePayloadOutput {100  entries: VaultEntry[];101  settings: VaultSettings;102  license?: PeachLicenseExport;103  declinedCardFingerprints?: string[];104  manualSummonDomains?: Record<string, ManualSummonRecord>;105}106107/**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 */113export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {114  const sections: Uint8Array[] = [];115116  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  }134135  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)141142  const body = concat([header, ...sections]);143  const checksum = await blake3Truncated(body);144  const inner = concat([body, checksum]);145146  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}156157/**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 */163export 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  }170171  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);175176  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  }185186  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");187188  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");192193  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];198199  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");215216    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    }237238    cursor = dataEnd;239  }240241  return out;242}243244// ---------------------------------------------------------------------------245// Section assembly246// ---------------------------------------------------------------------------247248async 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}258259function encodeJsonSection(value: unknown): Uint8Array {260  return new TextEncoder().encode(JSON.stringify(value));261}262263function decodeJsonSection<T>(data: Uint8Array): T {264  return JSON.parse(new TextDecoder().decode(data)) as T;265}266267// ---------------------------------------------------------------------------268// Entries section: shared string table + per-type packed bodies269// ---------------------------------------------------------------------------270271class StringTableBuilder {272  private map = new Map<string, number>();273  private list: string[] = [];274275  /** 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  }286287  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}307308function 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}322323function lookupString(strings: string[], idx: number): string | undefined {324  if (idx === 0) return undefined;325  return strings[idx - 1];326}327328function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {329  const table = new StringTableBuilder();330  const bodies: Uint8Array[] = [];331332  // Pre-intern title for every entry so the table is built consistently.333  for (const entry of entries) table.intern(entry.title);334335  for (const entry of entries) {336    bodies.push(encodeEntry(entry, table));337  }338339  const tableBytes = table.serialize();340341  // [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}355356function 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);360361  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}370371// ---------------------------------------------------------------------------372// Per-entry packing373// ---------------------------------------------------------------------------374375function 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));380381  // 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;386387  // flags placeholder (we'll overwrite at the end)388  const flagOffset = writer.length;389  writer.u8(0);390391  writer.u32(createdSec);392  writer.u32(updatedDelta);393  writer.u32(deletedSec);394  writer.u16(table.intern(entry.title));395396  let flags = 0;397  if (entry.deletedAt) flags |= F_HAS_DELETED;398399  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);403404    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    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.475    const pk = entry.passkey;476    const flat = entry as unknown as Record<string, unknown>;477    const blob = encodeJsonSection({478      rpId:             pk ? pk.rpId             : flat["rpId"],479      rpName:           pk ? pk.rpName           : flat["rpName"],480      credentialId:     pk ? pk.credentialId     : flat["credentialId"],481      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],482      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],483      userHandle:       pk ? pk.userHandle       : flat["userHandle"],484      userName:         pk ? pk.userName         : flat["userName"],485      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],486      algorithm:        pk ? pk.algorithm        : flat["algorithm"],487      signCount:        pk ? pk.signCount        : flat["signCount"],488      aaguid:           pk ? pk.aaguid           : flat["aaguid"],489      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],490      backupState:      pk ? pk.backupState      : flat["backupState"],491      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],492      // Preserve login fields for passkey-bearing login entries.493      ...(pk && entry.password  !== undefined ? { password:  entry.password  } : {}),494      ...(pk && entry.url       !== undefined ? { url:       entry.url       } : {}),495      ...(pk && entry.username  !== undefined ? { username:  entry.username  } : {}),496    });497    writer.u32(blob.length);498    writer.bytes(blob);499  }500501  writer.setU8(flagOffset, flags);502  return writer.toBytes();503}504505function decodeEntry(506  data: Uint8Array,507  start: number,508  strings: string[]509): { entry: VaultEntry; cursor: number } {510  const reader = new BinaryReader(data, start);511  const type = reader.u8();512  const id = bytesToUuid(reader.read(16));513  const flags = reader.u8();514  const createdSec = reader.u32();515  const updatedDelta = reader.u32();516  const deletedSec = reader.u32();517  const titleIdx = reader.u16();518519  const createdAt = createdSec * 1000;520  const modifiedAt = (createdSec + updatedDelta) * 1000;521  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;522  const title = lookupString(strings, titleIdx) ?? "";523524  const baseEntry: Partial<VaultEntry> = {525    id,526    title,527    createdAt,528    modifiedAt,529    ...(deletedAt !== undefined ? { deletedAt } : {}),530  };531532  if (type === ENTRY_TYPE_LOGIN) {533    const urlIdx = reader.u16();534    const usernameIdx = reader.u16();535    const password = reader.lpString(1);536    const tail: Partial<VaultEntry> = {};537538    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);539    if (flags & F_HAS_TAGS) {540      const cnt = reader.u8();541      const tags: string[] = [];542      for (let i = 0; i < cnt; i++) {543        const v = lookupString(strings, reader.u16());544        if (v !== undefined) tags.push(v);545      }546      tail.tags = tags;547    }548    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());549    if (flags & F_HAS_CANARY) {550      const webhookUrl = lookupString(strings, reader.u16()) ?? "";551      const label = lookupString(strings, reader.u16());552      const canary: Canary = { webhookUrl };553      if (label !== undefined) canary.label = label;554      tail.canary = canary;555    }556    if (flags & F_HAS_PWCHANGED) {557      const pwDelta = reader.u32();558      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;559    }560    if (flags & F_HAS_BREACH) tail.breach = reader.u32();561    if (flags & F_HAS_ADDITIONAL_URLS) {562      const cnt = reader.u8();563      const urls: string[] = [];564      for (let i = 0; i < cnt; i++) {565        const v = lookupString(strings, reader.u16());566        if (v !== undefined) urls.push(v);567      }568      tail.additionalUrls = urls;569    }570571    return {572      entry: {573        ...(baseEntry as VaultEntry),574        type: "login",575        url: lookupString(strings, urlIdx),576        username: lookupString(strings, usernameIdx),577        password,578        ...tail,579      } as VaultEntry,580      cursor: reader.cursor,581    };582  }583584  if (type === ENTRY_TYPE_NOTE) {585    const tail: Partial<VaultEntry> = {};586    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());587    if (flags & F_HAS_TAGS) {588      const cnt = reader.u8();589      const tags: string[] = [];590      for (let i = 0; i < cnt; i++) {591        const v = lookupString(strings, reader.u16());592        if (v !== undefined) tags.push(v);593      }594      tail.tags = tags;595    }596    return {597      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,598      cursor: reader.cursor,599    };600  }601602  if (type === ENTRY_TYPE_CARD) {603    const cardholderIdx = reader.u16();604    const cardNumber = reader.lpString(1);605    const expMonth = reader.lpString(1);606    const expYear = reader.lpString(1);607    const cvc = reader.lpString(1);608    const nicknameIdx = reader.u16();609    const network = networkFromByte(reader.u8());610    const last4Idx = reader.u16();611    const tail: Partial<VaultEntry> = {};612    if (flags & F_HAS_TAGS) {613      const cnt = reader.u8();614      const tags: string[] = [];615      for (let i = 0; i < cnt; i++) {616        const v = lookupString(strings, reader.u16());617        if (v !== undefined) tags.push(v);618      }619      tail.tags = tags;620    }621    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());622623    return {624      entry: {625        ...(baseEntry as VaultEntry),626        type: "card",627        cardholderName: lookupString(strings, cardholderIdx),628        cardNumber,629        expMonth,630        expYear,631        cvc,632        cardNickname: lookupString(strings, nicknameIdx),633        network,634        last4: lookupString(strings, last4Idx),635        ...tail,636      } as VaultEntry,637      cursor: reader.cursor,638    };639  }640641  if (type === ENTRY_TYPE_PASSKEY) {642    const blobLen = reader.u32();643    const blob = reader.read(blobLen);644    const obj = decodeJsonSection<Record<string, unknown>>(blob);645    return {646      entry: {647        ...(baseEntry as VaultEntry),648        type: "passkey",649        ...obj,650      } as VaultEntry,651      cursor: reader.cursor,652    };653  }654655  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);656}657658function entryTypeByte(entry: VaultEntry): number {659  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys660  // as type:"login" entries with a nested passkey sub-object, so the type661  // field alone is not sufficient to detect them.662  if (entry.passkey) return ENTRY_TYPE_PASSKEY;663  switch (entry.type) {664    case "note":665      return ENTRY_TYPE_NOTE;666    case "card":667      return ENTRY_TYPE_CARD;668    case "passkey":669      return ENTRY_TYPE_PASSKEY;670    default:671      return ENTRY_TYPE_LOGIN;672  }673}674675function networkToByte(network: CardNetwork | undefined): number {676  if (!network) return 0xff;677  const idx = NETWORK_VALUES.indexOf(network);678  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");679}680681function networkFromByte(b: number): CardNetwork | undefined {682  if (b === 0xff) return undefined;683  return NETWORK_VALUES[b] ?? "unknown";684}685686// ---------------------------------------------------------------------------687// UUID packing — 16 raw bytes vs 36-character hyphenated string688// ---------------------------------------------------------------------------689690export function uuidToBytes(uuid: string): Uint8Array {691  const hex = uuid.replace(/-/g, "");692  if (hex.length !== 32) throw new Error("UUID_FORMAT");693  const out = new Uint8Array(16);694  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);695  return out;696}697698export function bytesToUuid(bytes: Uint8Array): string {699  if (bytes.length !== 16) throw new Error("UUID_LENGTH");700  const hex = Array.from(bytes)701    .map((b) => b.toString(16).padStart(2, "0"))702    .join("");703  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;704}705706// ---------------------------------------------------------------------------707// Tiny binary reader/writer708// ---------------------------------------------------------------------------709710class BinaryWriter {711  private chunks: Uint8Array[] = [];712  private len = 0;713714  get length(): number {715    return this.len;716  }717718  u8(v: number): void {719    const b = new Uint8Array(1);720    b[0] = v & 0xff;721    this.push(b);722  }723724  setU8(offset: number, v: number): void {725    // Only used to back-patch the flags byte; always lands inside chunks[1] or726    // earlier. Reconstruct by walking chunks until we hit the offset.727    let cursor = 0;728    for (const c of this.chunks) {729      if (offset < cursor + c.length) {730        c[offset - cursor] = v & 0xff;731        return;732      }733      cursor += c.length;734    }735    throw new Error("setU8 out of range");736  }737738  u16(v: number): void {739    const b = new Uint8Array(2);740    new DataView(b.buffer).setUint16(0, v & 0xffff, true);741    this.push(b);742  }743744  u32(v: number): void {745    const b = new Uint8Array(4);746    new DataView(b.buffer).setUint32(0, v >>> 0, true);747    this.push(b);748  }749750  bytes(b: Uint8Array): void {751    this.push(b);752  }753754  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */755  lpString(value: string, lenSize: 1 | 2): void {756    const enc = new TextEncoder().encode(value);757    if (lenSize === 1) {758      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");759      this.u8(enc.length);760    } else {761      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");762      this.u16(enc.length);763    }764    this.push(enc);765  }766767  toBytes(): Uint8Array {768    const out = new Uint8Array(this.len);769    let cursor = 0;770    for (const c of this.chunks) {771      out.set(c, cursor);772      cursor += c.length;773    }774    return out;775  }776777  private push(b: Uint8Array): void {778    this.chunks.push(b);779    this.len += b.length;780  }781}782783class BinaryReader {784  cursor: number;785  private view: DataView;786  constructor(private data: Uint8Array, start: number) {787    this.cursor = start;788    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);789  }790  u8(): number {791    return this.data[this.cursor++];792  }793  u16(): number {794    const v = this.view.getUint16(this.cursor, true);795    this.cursor += 2;796    return v;797  }798  u32(): number {799    const v = this.view.getUint32(this.cursor, true);800    this.cursor += 4;801    return v;802  }803  read(n: number): Uint8Array {804    const out = this.data.subarray(this.cursor, this.cursor + n);805    this.cursor += n;806    return out;807  }808  lpString(lenSize: 1 | 2): string {809    const len = lenSize === 1 ? this.u8() : this.u16();810    return new TextDecoder().decode(this.read(len));811  }812}813814// ---------------------------------------------------------------------------815// Crypto helpers816// ---------------------------------------------------------------------------817818async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {819  return argon2id({820    password: passphrase,821    salt,822    parallelism: ARGON2_PARALLELISM,823    iterations: ARGON2_ITERATIONS,824    memorySize: ARGON2_MEMORY_KIB,825    hashLength: 32,826    outputType: "binary",827  });828}829830async function aesGcmEncrypt(831  key: Uint8Array,832  nonce: Uint8Array,833  plaintext: Uint8Array834): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {835  const cryptoKey = await crypto.subtle.importKey(836    "raw",837    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,838    { name: "AES-GCM" },839    false,840    ["encrypt"]841  );842  const raw = await crypto.subtle.encrypt(843    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },844    cryptoKey,845    plaintext.buffer.slice(846      plaintext.byteOffset,847      plaintext.byteOffset + plaintext.byteLength848    ) as ArrayBuffer849  );850  const out = new Uint8Array(raw);851  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };852}853854async function aesGcmDecrypt(855  key: Uint8Array,856  nonce: Uint8Array,857  ciphertext: Uint8Array,858  tag: Uint8Array859): Promise<Uint8Array> {860  const cryptoKey = await crypto.subtle.importKey(861    "raw",862    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,863    { name: "AES-GCM" },864    false,865    ["decrypt"]866  );867  const combined = concat([ciphertext, tag]);868  const raw = await crypto.subtle.decrypt(869    {870      name: "AES-GCM",871      iv: nonce.buffer.slice(872        nonce.byteOffset,873        nonce.byteOffset + nonce.byteLength874      ) as ArrayBuffer,875      tagLength: 128,876    },877    cryptoKey,878    combined.buffer.slice(879      combined.byteOffset,880      combined.byteOffset + combined.byteLength881    ) as ArrayBuffer882  );883  return new Uint8Array(raw);884}885886async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {887  const hex = await blake3(data);888  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.889  const out = new Uint8Array(CHECKSUM_LEN);890  for (let i = 0; i < CHECKSUM_LEN; i++) {891    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);892  }893  return out;894}895896function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {897  if (a.length !== b.length) return false;898  let diff = 0;899  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];900  return diff === 0;901}902903function concat(parts: Uint8Array[]): Uint8Array {904  let total = 0;905  for (const p of parts) total += p.length;906  const out = new Uint8Array(total);907  let cursor = 0;908  for (const p of parts) {909    out.set(p, cursor);910    cursor += p.length;911  }912  return out;913}