peachPayload.ts

31.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;5657// Size buckets (bytes) for payload padding. Coarsens ciphertext-size leakage58// so an attacker cannot infer vault size from file size. The bucket boundaries59// are powers of 4 starting at 1 KiB.60const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;6162// Pad `data` to the next SIZE_BUCKET boundary. The returned Uint8Array is63// zero-filled in the padding region — safe because it lives inside the AEAD.64function padToNearestBucket(data: Uint8Array): Uint8Array {65  const bucket = SIZE_BUCKETS.find((b) => b >= data.length);66  let targetSize: number;67  if (bucket !== undefined) {68    targetSize = bucket;69  } else {70    // Larger than the largest bucket — round up to the next power of two.71    targetSize = 262144;72    while (targetSize < data.length) targetSize *= 2;73  }74  if (targetSize === data.length) return data;75  const padded = new Uint8Array(targetSize);76  padded.set(data);77  return padded;78}7980// v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC81// and refuse gracefully; new clients detect v2 by this leading byte).82const FORMAT_V2 = 0x02;8384const ARGON2_MEMORY_KIB = 65536; // 64 MiB85const ARGON2_ITERATIONS = 3;86const ARGON2_PARALLELISM = 4;8788const SECTION_ENTRIES = 0x01;89const SECTION_SETTINGS = 0x02;90const SECTION_LICENSE = 0x03;91const SECTION_RECALL = 0x04;92const SECTION_DECLINED_CARDS = 0x05;9394const ENTRY_TYPE_LOGIN = 0x01;95const ENTRY_TYPE_NOTE = 0x02;96const ENTRY_TYPE_CARD = 0x03;97const ENTRY_TYPE_PASSKEY = 0x04;9899// Login entry flag bits100const F_HAS_TOTP = 1 << 0;101const F_HAS_TAGS = 1 << 1;102const F_HAS_NOTES = 1 << 2;103const F_HAS_CANARY = 1 << 3;104const F_HAS_PWCHANGED = 1 << 4;105const F_HAS_BREACH = 1 << 5;106const F_HAS_ADDITIONAL_URLS = 1 << 6;107const F_HAS_DELETED = 1 << 7;108109const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];110111// ---------------------------------------------------------------------------112// Public API113// ---------------------------------------------------------------------------114115export interface BuildPayloadInput {116  entries: VaultEntry[];117  settings: VaultSettings;118  license?: PeachLicenseExport;119  /** Optional declined card fingerprints (subset of settings — split out per spec). */120  declinedCardFingerprints?: string[];121  /** Optional recall map (subset of settings — split out per spec). */122  manualSummonDomains?: Record<string, ManualSummonRecord>;123  passphrase: string;124}125126export interface ParsePayloadOutput {127  entries: VaultEntry[];128  settings: VaultSettings;129  license?: PeachLicenseExport;130  declinedCardFingerprints?: string[];131  manualSummonDomains?: Record<string, ManualSummonRecord>;132}133134/**135 * Build the encrypted envelope: assemble sections → BLAKE3-checksum →136 * AES-256-GCM encrypt under an Argon2id-derived key. Returns137 * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into138 * `encodePeachScript` or the QR matrix encoder.139 */140export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {141  const sections: Uint8Array[] = [];142143  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries)));144  sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));145  if (input.license) {146    sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));147  }148  if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {149    sections.push(150      await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))151    );152  }153  if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {154    sections.push(155      await buildSection(156        SECTION_DECLINED_CARDS,157        encodeJsonSection(input.declinedCardFingerprints)158      )159    );160  }161162  const header = new Uint8Array(HEADER_LEN);163  const headerView = new DataView(header.buffer);164  headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump165  header[4] = VERSION;166  header[5] = sections.length;167  // bytes 6,7: reserved (zero)168169  const body = concat([header, ...sections]);170  const checksum = await blake3Truncated(body);171  const inner = concat([body, checksum]);172173  // v2 format: [FORMAT_V2(1)][inner_len(4 LE)][inner][zero padding to bucket]174  // Old clients see FORMAT_V2 (0x02) where they expect PCHS magic (0x50) and175  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent176  // wrong-password failure.177  const header5 = new Uint8Array(5);178  header5[0] = FORMAT_V2;179  new DataView(header5.buffer).setUint32(1, inner.length, true);180  const wrapped = padToNearestBucket(concat([header5, inner]));181182  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));183  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));184  const key = await deriveKey(input.passphrase, salt);185  try {186    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);187    return concat([salt, nonce, ciphertext, tag]);188  } finally {189    key.fill(0);190  }191}192193/**194 * Parse the encrypted envelope back to a typed payload. Throws195 * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a196 * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or197 * `CHECKSUM_FAILED` on a corrupt inner payload.198 */199export async function parsePayload(200  envelope: Uint8Array,201  passphrase: string202): Promise<ParsePayloadOutput> {203  if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {204    throw new Error("PAYLOAD_TOO_SHORT");205  }206207  const salt = envelope.subarray(0, SALT_LEN);208  const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);209  const tag = envelope.subarray(envelope.length - TAG_LEN);210  const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);211212  const key = await deriveKey(passphrase, salt);213  let decrypted: Uint8Array;214  try {215    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);216  } catch {217    throw new Error("INVALID_PASSPHRASE");218  } finally {219    key.fill(0);220  }221222  // Detect v2 padded format: leading byte FORMAT_V2 followed by 4-byte inner length.223  let inner: Uint8Array;224  if (decrypted.length >= 5 && decrypted[0] === FORMAT_V2) {225    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);226    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");227    inner = decrypted.subarray(5, 5 + innerLen);228  } else {229    inner = decrypted; // v1 format — no length prefix, no padding230  }231232  if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");233234  const body = inner.subarray(0, inner.length - CHECKSUM_LEN);235  const stored = inner.subarray(inner.length - CHECKSUM_LEN);236  const computed = await blake3Truncated(body);237  if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");238239  const view = new DataView(body.buffer, body.byteOffset, body.byteLength);240  if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");241  const version = body[4];242  if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);243  const sectionCount = body[5];244245  let cursor = HEADER_LEN;246  const out: ParsePayloadOutput = {247    entries: [],248    settings: {},249  };250  for (let i = 0; i < sectionCount; i++) {251    if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");252    const type = body[cursor];253    const length = view.getUint32(cursor + 1, true);254    const clength = view.getUint32(cursor + 5, true);255    const dataStart = cursor + 9;256    const dataEnd = dataStart + clength;257    if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");258    const compressed = body.subarray(dataStart, dataEnd);259    const data = await decompressZstd(compressed);260    if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");261262    switch (type) {263      case SECTION_ENTRIES:264        out.entries = decodeEntriesSection(data);265        break;266      case SECTION_SETTINGS:267        out.settings = decodeJsonSection<VaultSettings>(data);268        break;269      case SECTION_LICENSE:270        out.license = decodeJsonSection<PeachLicenseExport>(data);271        break;272      case SECTION_RECALL:273        out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);274        break;275      case SECTION_DECLINED_CARDS:276        out.declinedCardFingerprints = decodeJsonSection<string[]>(data);277        break;278      default:279        // Unknown section — skip rather than fail, so future-introduced280        // sections do not break older clients reading newer payloads.281        break;282    }283284    cursor = dataEnd;285  }286287  return out;288}289290// ---------------------------------------------------------------------------291// Section assembly292// ---------------------------------------------------------------------------293294async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {295  const compressed = await compressZstd(raw, 19);296  const out = new Uint8Array(9 + compressed.length);297  const view = new DataView(out.buffer);298  out[0] = type;299  view.setUint32(1, raw.length, true);300  view.setUint32(5, compressed.length, true);301  out.set(compressed, 9);302  return out;303}304305function encodeJsonSection(value: unknown): Uint8Array {306  return new TextEncoder().encode(JSON.stringify(value));307}308309function decodeJsonSection<T>(data: Uint8Array): T {310  return JSON.parse(new TextDecoder().decode(data)) as T;311}312313// ---------------------------------------------------------------------------314// Entries section: shared string table + per-type packed bodies315// ---------------------------------------------------------------------------316317class StringTableBuilder {318  private map = new Map<string, number>();319  private list: string[] = [];320321  /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */322  intern(value: string | undefined): number {323    if (!value) return 0;324    const existing = this.map.get(value);325    if (existing !== undefined) return existing;326    const idx = this.list.length + 1; // 1-indexed; 0 = absent327    if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");328    this.map.set(value, idx);329    this.list.push(value);330    return idx;331  }332333  serialize(): Uint8Array {334    const enc = new TextEncoder();335    const encoded = this.list.map((s) => enc.encode(s));336    let total = 2; // u16 count337    for (const e of encoded) total += 2 + e.length;338    const out = new Uint8Array(total);339    const view = new DataView(out.buffer);340    let cursor = 0;341    view.setUint16(cursor, encoded.length, true);342    cursor += 2;343    for (const e of encoded) {344      if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");345      view.setUint16(cursor, e.length, true);346      cursor += 2;347      out.set(e, cursor);348      cursor += e.length;349    }350    return out;351  }352}353354function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {355  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);356  const count = view.getUint16(cursor, true);357  cursor += 2;358  const strings: string[] = [];359  const dec = new TextDecoder();360  for (let i = 0; i < count; i++) {361    const len = view.getUint16(cursor, true);362    cursor += 2;363    strings.push(dec.decode(data.subarray(cursor, cursor + len)));364    cursor += len;365  }366  return { strings, cursor };367}368369function lookupString(strings: string[], idx: number): string | undefined {370  if (idx === 0) return undefined;371  return strings[idx - 1];372}373374function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {375  const table = new StringTableBuilder();376  const bodies: Uint8Array[] = [];377378  // Pre-intern title for every entry so the table is built consistently.379  for (const entry of entries) table.intern(entry.title);380381  for (const entry of entries) {382    bodies.push(encodeEntry(entry, table));383  }384385  const tableBytes = table.serialize();386387  // [u32 count][string_table][entries...]388  let bodiesLen = 0;389  for (const b of bodies) bodiesLen += b.length;390  const out = new Uint8Array(4 + tableBytes.length + bodiesLen);391  const view = new DataView(out.buffer);392  view.setUint32(0, entries.length, true);393  out.set(tableBytes, 4);394  let cursor = 4 + tableBytes.length;395  for (const b of bodies) {396    out.set(b, cursor);397    cursor += b.length;398  }399  return out;400}401402function decodeEntriesSection(data: Uint8Array): VaultEntry[] {403  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);404  const count = view.getUint32(0, true);405  const { strings, cursor: afterTable } = readStringTable(data, 4);406407  const out: VaultEntry[] = [];408  let cursor = afterTable;409  for (let i = 0; i < count; i++) {410    const { entry, cursor: next } = decodeEntry(data, cursor, strings);411    out.push(entry);412    cursor = next;413  }414  return out;415}416417// ---------------------------------------------------------------------------418// Per-entry packing419// ---------------------------------------------------------------------------420421function encodeEntry(entry: VaultEntry, table: StringTableBuilder): Uint8Array {422  const writer = new BinaryWriter();423  const type = entryTypeByte(entry);424  writer.u8(type);425  writer.bytes(uuidToBytes(entry.id));426427  // common: created_at (seconds), updated_at_delta, deleted_at, title_idx428  const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);429  const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);430  const updatedDelta = Math.max(0, updatedSec - createdSec);431  const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;432433  // flags placeholder (we'll overwrite at the end)434  const flagOffset = writer.length;435  writer.u8(0);436437  writer.u32(createdSec);438  writer.u32(updatedDelta);439  writer.u32(deletedSec);440  writer.u16(table.intern(entry.title));441442  let flags = 0;443  if (entry.deletedAt) flags |= F_HAS_DELETED;444445  if (type === ENTRY_TYPE_LOGIN) {446    writer.u16(table.intern(entry.url));447    writer.u16(table.intern(entry.username));448    writer.lpString(entry.password ?? "", 1);449450    if (entry.totp) {451      flags |= F_HAS_TOTP;452      writer.lpString(entry.totp, 1);453    }454    if (entry.tags && entry.tags.length > 0) {455      flags |= F_HAS_TAGS;456      writer.u8(Math.min(entry.tags.length, 0xff));457      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {458        writer.u16(table.intern(entry.tags[i]));459      }460    }461    if (entry.notes) {462      flags |= F_HAS_NOTES;463      writer.u16(table.intern(entry.notes));464    }465    if (entry.canary) {466      flags |= F_HAS_CANARY;467      writer.u16(table.intern(entry.canary.webhookUrl));468      writer.u16(table.intern(entry.canary.label));469    }470    if (entry.passwordChangedAt) {471      flags |= F_HAS_PWCHANGED;472      const pwSec = Math.floor(entry.passwordChangedAt / 1000);473      writer.u32(Math.max(0, pwSec - createdSec));474    }475    if (typeof entry.breach === "number" && entry.breach > 0) {476      flags |= F_HAS_BREACH;477      writer.u32(entry.breach);478    }479    if (entry.additionalUrls && entry.additionalUrls.length > 0) {480      flags |= F_HAS_ADDITIONAL_URLS;481      writer.u8(Math.min(entry.additionalUrls.length, 0xff));482      for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {483        writer.u16(table.intern(entry.additionalUrls[i]));484      }485    }486  } else if (type === ENTRY_TYPE_NOTE) {487    if (entry.notes) {488      flags |= F_HAS_NOTES;489      writer.u16(table.intern(entry.notes));490    }491    if (entry.tags && entry.tags.length > 0) {492      flags |= F_HAS_TAGS;493      writer.u8(Math.min(entry.tags.length, 0xff));494      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {495        writer.u16(table.intern(entry.tags[i]));496      }497    }498  } else if (type === ENTRY_TYPE_CARD) {499    writer.u16(table.intern(entry.cardholderName));500    writer.lpString(entry.cardNumber ?? "", 1);501    writer.lpString(entry.expMonth ?? "", 1);502    writer.lpString(entry.expYear ?? "", 1);503    writer.lpString(entry.cvc ?? "", 1);504    writer.u16(table.intern(entry.cardNickname));505    writer.u8(networkToByte(entry.network));506    writer.u16(table.intern(entry.last4));507    if (entry.tags && entry.tags.length > 0) {508      flags |= F_HAS_TAGS;509      writer.u8(Math.min(entry.tags.length, 0xff));510      for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {511        writer.u16(table.intern(entry.tags[i]));512      }513    }514    if (entry.notes) {515      flags |= F_HAS_NOTES;516      writer.u16(table.intern(entry.notes));517    }518  } else if (type === ENTRY_TYPE_PASSKEY) {519    // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.520    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.521    const pk = entry.passkey;522    const flat = entry as unknown as Record<string, unknown>;523    const blob = encodeJsonSection({524      rpId:             pk ? pk.rpId             : flat["rpId"],525      rpName:           pk ? pk.rpName           : flat["rpName"],526      credentialId:     pk ? pk.credentialId     : flat["credentialId"],527      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],528      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],529      userHandle:       pk ? pk.userHandle       : flat["userHandle"],530      userName:         pk ? pk.userName         : flat["userName"],531      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],532      algorithm:        pk ? pk.algorithm        : flat["algorithm"],533      signCount:        pk ? pk.signCount        : flat["signCount"],534      aaguid:           pk ? pk.aaguid           : flat["aaguid"],535      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],536      backupState:      pk ? pk.backupState      : flat["backupState"],537      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],538      // Preserve login fields for passkey-bearing login entries.539      ...(pk && entry.password  !== undefined ? { password:  entry.password  } : {}),540      ...(pk && entry.url       !== undefined ? { url:       entry.url       } : {}),541      ...(pk && entry.username  !== undefined ? { username:  entry.username  } : {}),542    });543    writer.u32(blob.length);544    writer.bytes(blob);545  }546547  writer.setU8(flagOffset, flags);548  return writer.toBytes();549}550551function decodeEntry(552  data: Uint8Array,553  start: number,554  strings: string[]555): { entry: VaultEntry; cursor: number } {556  const reader = new BinaryReader(data, start);557  const type = reader.u8();558  const id = bytesToUuid(reader.read(16));559  const flags = reader.u8();560  const createdSec = reader.u32();561  const updatedDelta = reader.u32();562  const deletedSec = reader.u32();563  const titleIdx = reader.u16();564565  const createdAt = createdSec * 1000;566  const modifiedAt = (createdSec + updatedDelta) * 1000;567  const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;568  const title = lookupString(strings, titleIdx) ?? "";569570  const baseEntry: Partial<VaultEntry> = {571    id,572    title,573    createdAt,574    modifiedAt,575    ...(deletedAt !== undefined ? { deletedAt } : {}),576  };577578  if (type === ENTRY_TYPE_LOGIN) {579    const urlIdx = reader.u16();580    const usernameIdx = reader.u16();581    const password = reader.lpString(1);582    const tail: Partial<VaultEntry> = {};583584    if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);585    if (flags & F_HAS_TAGS) {586      const cnt = reader.u8();587      const tags: string[] = [];588      for (let i = 0; i < cnt; i++) {589        const v = lookupString(strings, reader.u16());590        if (v !== undefined) tags.push(v);591      }592      tail.tags = tags;593    }594    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());595    if (flags & F_HAS_CANARY) {596      const webhookUrl = lookupString(strings, reader.u16()) ?? "";597      const label = lookupString(strings, reader.u16());598      const canary: Canary = { webhookUrl };599      if (label !== undefined) canary.label = label;600      tail.canary = canary;601    }602    if (flags & F_HAS_PWCHANGED) {603      const pwDelta = reader.u32();604      tail.passwordChangedAt = (createdSec + pwDelta) * 1000;605    }606    if (flags & F_HAS_BREACH) tail.breach = reader.u32();607    if (flags & F_HAS_ADDITIONAL_URLS) {608      const cnt = reader.u8();609      const urls: string[] = [];610      for (let i = 0; i < cnt; i++) {611        const v = lookupString(strings, reader.u16());612        if (v !== undefined) urls.push(v);613      }614      tail.additionalUrls = urls;615    }616617    return {618      entry: {619        ...(baseEntry as VaultEntry),620        type: "login",621        url: lookupString(strings, urlIdx),622        username: lookupString(strings, usernameIdx),623        password,624        ...tail,625      } as VaultEntry,626      cursor: reader.cursor,627    };628  }629630  if (type === ENTRY_TYPE_NOTE) {631    const tail: Partial<VaultEntry> = {};632    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());633    if (flags & F_HAS_TAGS) {634      const cnt = reader.u8();635      const tags: string[] = [];636      for (let i = 0; i < cnt; i++) {637        const v = lookupString(strings, reader.u16());638        if (v !== undefined) tags.push(v);639      }640      tail.tags = tags;641    }642    return {643      entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,644      cursor: reader.cursor,645    };646  }647648  if (type === ENTRY_TYPE_CARD) {649    const cardholderIdx = reader.u16();650    const cardNumber = reader.lpString(1);651    const expMonth = reader.lpString(1);652    const expYear = reader.lpString(1);653    const cvc = reader.lpString(1);654    const nicknameIdx = reader.u16();655    const network = networkFromByte(reader.u8());656    const last4Idx = reader.u16();657    const tail: Partial<VaultEntry> = {};658    if (flags & F_HAS_TAGS) {659      const cnt = reader.u8();660      const tags: string[] = [];661      for (let i = 0; i < cnt; i++) {662        const v = lookupString(strings, reader.u16());663        if (v !== undefined) tags.push(v);664      }665      tail.tags = tags;666    }667    if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());668669    return {670      entry: {671        ...(baseEntry as VaultEntry),672        type: "card",673        cardholderName: lookupString(strings, cardholderIdx),674        cardNumber,675        expMonth,676        expYear,677        cvc,678        cardNickname: lookupString(strings, nicknameIdx),679        network,680        last4: lookupString(strings, last4Idx),681        ...tail,682      } as VaultEntry,683      cursor: reader.cursor,684    };685  }686687  if (type === ENTRY_TYPE_PASSKEY) {688    const blobLen = reader.u32();689    const blob = reader.read(blobLen);690    const obj = decodeJsonSection<Record<string, unknown>>(blob);691    return {692      entry: {693        ...(baseEntry as VaultEntry),694        type: "passkey",695        ...obj,696      } as VaultEntry,697      cursor: reader.cursor,698    };699  }700701  throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);702}703704function entryTypeByte(entry: VaultEntry): number {705  // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys706  // as type:"login" entries with a nested passkey sub-object, so the type707  // field alone is not sufficient to detect them.708  if (entry.passkey) return ENTRY_TYPE_PASSKEY;709  switch (entry.type) {710    case "note":711      return ENTRY_TYPE_NOTE;712    case "card":713      return ENTRY_TYPE_CARD;714    case "passkey":715      return ENTRY_TYPE_PASSKEY;716    default:717      return ENTRY_TYPE_LOGIN;718  }719}720721function networkToByte(network: CardNetwork | undefined): number {722  if (!network) return 0xff;723  const idx = NETWORK_VALUES.indexOf(network);724  return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");725}726727function networkFromByte(b: number): CardNetwork | undefined {728  if (b === 0xff) return undefined;729  return NETWORK_VALUES[b] ?? "unknown";730}731732// ---------------------------------------------------------------------------733// UUID packing — 16 raw bytes vs 36-character hyphenated string734// ---------------------------------------------------------------------------735736export function uuidToBytes(uuid: string): Uint8Array {737  const hex = uuid.replace(/-/g, "");738  if (hex.length !== 32) throw new Error("UUID_FORMAT");739  const out = new Uint8Array(16);740  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);741  return out;742}743744export function bytesToUuid(bytes: Uint8Array): string {745  if (bytes.length !== 16) throw new Error("UUID_LENGTH");746  const hex = Array.from(bytes)747    .map((b) => b.toString(16).padStart(2, "0"))748    .join("");749  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;750}751752// ---------------------------------------------------------------------------753// Tiny binary reader/writer754// ---------------------------------------------------------------------------755756class BinaryWriter {757  private chunks: Uint8Array[] = [];758  private len = 0;759760  get length(): number {761    return this.len;762  }763764  u8(v: number): void {765    const b = new Uint8Array(1);766    b[0] = v & 0xff;767    this.push(b);768  }769770  setU8(offset: number, v: number): void {771    // Only used to back-patch the flags byte; always lands inside chunks[1] or772    // earlier. Reconstruct by walking chunks until we hit the offset.773    let cursor = 0;774    for (const c of this.chunks) {775      if (offset < cursor + c.length) {776        c[offset - cursor] = v & 0xff;777        return;778      }779      cursor += c.length;780    }781    throw new Error("setU8 out of range");782  }783784  u16(v: number): void {785    const b = new Uint8Array(2);786    new DataView(b.buffer).setUint16(0, v & 0xffff, true);787    this.push(b);788  }789790  u32(v: number): void {791    const b = new Uint8Array(4);792    new DataView(b.buffer).setUint32(0, v >>> 0, true);793    this.push(b);794  }795796  bytes(b: Uint8Array): void {797    this.push(b);798  }799800  /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */801  lpString(value: string, lenSize: 1 | 2): void {802    const enc = new TextEncoder().encode(value);803    if (lenSize === 1) {804      if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");805      this.u8(enc.length);806    } else {807      if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");808      this.u16(enc.length);809    }810    this.push(enc);811  }812813  toBytes(): Uint8Array {814    const out = new Uint8Array(this.len);815    let cursor = 0;816    for (const c of this.chunks) {817      out.set(c, cursor);818      cursor += c.length;819    }820    return out;821  }822823  private push(b: Uint8Array): void {824    this.chunks.push(b);825    this.len += b.length;826  }827}828829class BinaryReader {830  cursor: number;831  private view: DataView;832  constructor(private data: Uint8Array, start: number) {833    this.cursor = start;834    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);835  }836  u8(): number {837    return this.data[this.cursor++];838  }839  u16(): number {840    const v = this.view.getUint16(this.cursor, true);841    this.cursor += 2;842    return v;843  }844  u32(): number {845    const v = this.view.getUint32(this.cursor, true);846    this.cursor += 4;847    return v;848  }849  read(n: number): Uint8Array {850    const out = this.data.subarray(this.cursor, this.cursor + n);851    this.cursor += n;852    return out;853  }854  lpString(lenSize: 1 | 2): string {855    const len = lenSize === 1 ? this.u8() : this.u16();856    return new TextDecoder().decode(this.read(len));857  }858}859860// ---------------------------------------------------------------------------861// Crypto helpers862// ---------------------------------------------------------------------------863864async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {865  return argon2id({866    password: passphrase,867    salt,868    parallelism: ARGON2_PARALLELISM,869    iterations: ARGON2_ITERATIONS,870    memorySize: ARGON2_MEMORY_KIB,871    hashLength: 32,872    outputType: "binary",873  });874}875876async function aesGcmEncrypt(877  key: Uint8Array,878  nonce: Uint8Array,879  plaintext: Uint8Array880): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {881  const cryptoKey = await crypto.subtle.importKey(882    "raw",883    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,884    { name: "AES-GCM" },885    false,886    ["encrypt"]887  );888  const raw = await crypto.subtle.encrypt(889    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },890    cryptoKey,891    plaintext.buffer.slice(892      plaintext.byteOffset,893      plaintext.byteOffset + plaintext.byteLength894    ) as ArrayBuffer895  );896  const out = new Uint8Array(raw);897  return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };898}899900async function aesGcmDecrypt(901  key: Uint8Array,902  nonce: Uint8Array,903  ciphertext: Uint8Array,904  tag: Uint8Array905): Promise<Uint8Array> {906  const cryptoKey = await crypto.subtle.importKey(907    "raw",908    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,909    { name: "AES-GCM" },910    false,911    ["decrypt"]912  );913  const combined = concat([ciphertext, tag]);914  const raw = await crypto.subtle.decrypt(915    {916      name: "AES-GCM",917      iv: nonce.buffer.slice(918        nonce.byteOffset,919        nonce.byteOffset + nonce.byteLength920      ) as ArrayBuffer,921      tagLength: 128,922    },923    cryptoKey,924    combined.buffer.slice(925      combined.byteOffset,926      combined.byteOffset + combined.byteLength927    ) as ArrayBuffer928  );929  return new Uint8Array(raw);930}931932async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {933  const hex = await blake3(data);934  // Take first CHECKSUM_LEN bytes of the BLAKE3 hash.935  const out = new Uint8Array(CHECKSUM_LEN);936  for (let i = 0; i < CHECKSUM_LEN; i++) {937    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);938  }939  return out;940}941942function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {943  if (a.length !== b.length) return false;944  let diff = 0;945  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];946  return diff === 0;947}948949function concat(parts: Uint8Array[]): Uint8Array {950  let total = 0;951  for (const p of parts) total += p.length;952  const out = new Uint8Array(total);953  let cursor = 0;954  for (const p of parts) {955    out.set(p, cursor);956    cursor += p.length;957  }958  return out;959}