/** * peachPayload.ts — binary payload format shared by PeachScript and the * Peach QR matrix code (spec §2). * * [HEADER] 8 bytes — magic "PCHS", version, section count * [SECTION...] variable — typed sections, each independently zstd-compressed * [CHECKSUM] 4 bytes — BLAKE3 (truncated) of all preceding bytes * * The compressed payload is encrypted with AES-256-GCM under a key derived * via Argon2id(passphrase, salt, m=65536 KiB, t=3, p=4): * * [SALT 16][NONCE 12][CIPHERTEXT][TAG 16] * * That envelope is what `buildPayload`/`parsePayload` produce and consume. * * Sections used: * ENTRIES (0x01) — binary-packed vault entries with shared string * table + delta timestamps + raw UUID bytes * (login/note/card types); passkeys store as a * JSON blob within the same section because of * JWK structural complexity. * SETTINGS (0x02) — JSON-encoded VaultSettings * LICENSE (0x03) — JSON: { key, activatedAt? } * RECALL (0x04) — JSON: manualSummonDomains map * DECLINED_CARDS (0x05) — JSON: declined card fingerprints * * Tampering is detected by AES-GCM's auth tag. The trailing BLAKE3 checksum * lives inside the encrypted envelope and exists to flag compressed-payload * corruption pre-decompression so we can fail fast with a clear error. */ import { argon2id, blake3 } from "hash-wasm"; import { compressZstd, decompressZstd } from "./zstd"; import { IMPORT_LIMITS, importTooLarge } from "../shared/import-limits"; import type { Canary, CardNetwork, ManualSummonRecord, PeachLicenseExport, VaultEntry, } from "../shared/types"; import type { VaultSettings } from "../background/vault"; // --------------------------------------------------------------------------- // Header / section constants // --------------------------------------------------------------------------- export const MAGIC = 0x50434853; // "PCHS" export const VERSION = 0x01; export const HEADER_LEN = 8; export const CHECKSUM_LEN = 4; export const SALT_LEN = 16; export const NONCE_LEN = 12; export const TAG_LEN = 16; // Size buckets (bytes) for payload padding. Coarsens ciphertext-size leakage // so an attacker cannot infer vault size from file size. The bucket boundaries // are powers of 4 starting at 1 KiB. const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const; // Pad `data` to the next SIZE_BUCKET boundary. The returned Uint8Array is // zero-filled in the padding region — safe because it lives inside the AEAD. function padToNearestBucket(data: Uint8Array): Uint8Array { const bucket = SIZE_BUCKETS.find((b) => b >= data.length); let targetSize: number; if (bucket !== undefined) { targetSize = bucket; } else { // Larger than the largest bucket — round up to the next power of two. targetSize = 262144; while (targetSize < data.length) targetSize *= 2; } if (targetSize === data.length) return data; const padded = new Uint8Array(targetSize); padded.set(data); return padded; } // V3 added login flags2/email. V4 widens only login passwords to a u16 // byte-length prefix. Readers retain V3 compatibility; writers emit V4. const FORMAT_V3 = 0x03; const FORMAT_V4 = 0x04; const ARGON2_MEMORY_KIB = 65536; // 64 MiB const ARGON2_ITERATIONS = 3; const ARGON2_PARALLELISM = 4; const SECTION_ENTRIES = 0x01; const SECTION_SETTINGS = 0x02; const SECTION_LICENSE = 0x03; const SECTION_RECALL = 0x04; const SECTION_DECLINED_CARDS = 0x05; const ENTRY_TYPE_LOGIN = 0x01; const ENTRY_TYPE_NOTE = 0x02; const ENTRY_TYPE_CARD = 0x03; const ENTRY_TYPE_PASSKEY = 0x04; // Login entry flag bits const F_HAS_TOTP = 1 << 0; const F_HAS_TAGS = 1 << 1; const F_HAS_NOTES = 1 << 2; const F_HAS_CANARY = 1 << 3; const F_HAS_PWCHANGED = 1 << 4; const F_HAS_BREACH = 1 << 5; const F_HAS_ADDITIONAL_URLS = 1 << 6; const F_HAS_DELETED = 1 << 7; // v3-only flags2 byte (login entries only). Reserves room for further // post-v3.6.82 fields without another envelope bump. // // CAUTION: flags2 bits are read positionally within FORMAT_V3. Adding a new // boolean flag bit here does NOT require a format bump. But adding a new // VARIABLE-LENGTH field gated by a new flag bit DOES — older v3 decoders don't // know to skip the new field and the read cursor desyncs for everything after // it. If you need a new variable-length field, bump FORMAT_V3 → FORMAT_V4 and // gate the decoder on the format byte. Only add single-bit booleans to flags2. const F2_HAS_EMAIL = 1 << 0; const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"]; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface BuildPayloadInput { entries: VaultEntry[]; settings: VaultSettings; license?: PeachLicenseExport; /** Optional declined card fingerprints (subset of settings — split out per spec). */ declinedCardFingerprints?: string[]; /** Optional recall map (subset of settings — split out per spec). */ manualSummonDomains?: Record; passphrase: string; } export interface ParsePayloadOutput { entries: VaultEntry[]; settings: VaultSettings; license?: PeachLicenseExport; declinedCardFingerprints?: string[]; manualSummonDomains?: Record; } /** * Build the encrypted envelope: assemble sections → BLAKE3-checksum → * AES-256-GCM encrypt under an Argon2id-derived key. Returns * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into * `encodePeachScript` or the QR matrix encoder. */ export async function buildPayload( input: BuildPayloadInput, ): Promise { const sections: Uint8Array[] = []; sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V4))); sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings))); if (input.license) { sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license))); } if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) { sections.push( await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains)) ); } if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) { sections.push( await buildSection( SECTION_DECLINED_CARDS, encodeJsonSection(input.declinedCardFingerprints) ) ); } const header = new Uint8Array(HEADER_LEN); const headerView = new DataView(header.buffer); headerView.setUint32(0, MAGIC, false); // big-endian — readable as "PCHS" in a hex dump header[4] = VERSION; header[5] = sections.length; // bytes 6,7: reserved (zero) const body = concat([header, ...sections]); const checksum = await blake3Truncated(body); const inner = concat([body, checksum]); // v4 format: [FORMAT_V4(1)][inner_len(4 LE)][inner][zero padding to bucket] // Old clients see FORMAT_V4 (0x04) where they expect PCHS magic (0x50) and // fail with BAD_MAGIC — an honest "can't read this" rather than a silent // wrong-password failure. V4 keeps the v3 layout except for the widened // login-password length prefix (see encodeEntry/decodeEntry). const header5 = new Uint8Array(5); header5[0] = FORMAT_V4; new DataView(header5.buffer).setUint32(1, inner.length, true); const wrapped = padToNearestBucket(concat([header5, inner])); const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); const key = await deriveKey(input.passphrase, salt); try { const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped); return concat([salt, nonce, ciphertext, tag]); } finally { key.fill(0); } } /** * Parse the encrypted envelope back to a typed payload. Throws * `INVALID_PASSPHRASE` on auth-tag failure, `PAYLOAD_TOO_SHORT` on a * structurally invalid envelope, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, or * `CHECKSUM_FAILED` on a corrupt inner payload. */ export async function parsePayload( envelope: Uint8Array, passphrase: string ): Promise { if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) { throw new Error("PAYLOAD_TOO_SHORT"); } const salt = envelope.subarray(0, SALT_LEN); const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN); const tag = envelope.subarray(envelope.length - TAG_LEN); const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN); let decrypted: Uint8Array; const key = await deriveKey(passphrase, salt); try { decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag); } catch { throw new Error("INVALID_PASSPHRASE"); } finally { key.fill(0); } // Current envelopes always carry the version byte and length prefix. if (decrypted.length < 5 || (decrypted[0] !== FORMAT_V3 && decrypted[0] !== FORMAT_V4)) { throw new Error("UNSUPPORTED_VERSION"); } const formatVersion = decrypted[0]; const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true); if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT"); const inner = decrypted.subarray(5, 5 + innerLen); if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT"); const body = inner.subarray(0, inner.length - CHECKSUM_LEN); const stored = inner.subarray(inner.length - CHECKSUM_LEN); const computed = await blake3Truncated(body); if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED"); const view = new DataView(body.buffer, body.byteOffset, body.byteLength); if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC"); const version = body[4]; if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`); const sectionCount = body[5]; let cursor = HEADER_LEN; const out: ParsePayloadOutput = { entries: [], settings: {}, }; let totalDecompressed = 0; for (let i = 0; i < sectionCount; i++) { if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT"); const type = body[cursor]; const length = view.getUint32(cursor + 1, true); const clength = view.getUint32(cursor + 5, true); // MV-4: `length` is attacker-declared. Refuse before decompressing — // never let a 100-byte section promise (and zstd deliver) gigabytes. if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) { throw importTooLarge( `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`, ); } // MV-4: cap the total decompressed budget across all sections too, so // many maximal sections can't multiply past the single-section cap. totalDecompressed += length; if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) { throw importTooLarge("total decompressed payload exceeds limit"); } const dataStart = cursor + 9; const dataEnd = dataStart + clength; if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT"); const compressed = body.subarray(dataStart, dataEnd); const data = await decompressZstd(compressed); if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH"); switch (type) { case SECTION_ENTRIES: out.entries = decodeEntriesSection(data, formatVersion); break; case SECTION_SETTINGS: out.settings = decodeJsonSection(data); break; case SECTION_LICENSE: out.license = decodeJsonSection(data); break; case SECTION_RECALL: out.manualSummonDomains = decodeJsonSection>(data); break; case SECTION_DECLINED_CARDS: out.declinedCardFingerprints = decodeJsonSection(data); break; default: // Unknown section — skip rather than fail, so future-introduced // sections do not break older clients reading newer payloads. break; } cursor = dataEnd; } return out; } // --------------------------------------------------------------------------- // Section assembly // --------------------------------------------------------------------------- async function buildSection(type: number, raw: Uint8Array): Promise { const compressed = await compressZstd(raw, 19); const out = new Uint8Array(9 + compressed.length); const view = new DataView(out.buffer); out[0] = type; view.setUint32(1, raw.length, true); view.setUint32(5, compressed.length, true); out.set(compressed, 9); return out; } function encodeJsonSection(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } function decodeJsonSection(data: Uint8Array): T { return JSON.parse(new TextDecoder().decode(data)) as T; } // --------------------------------------------------------------------------- // Entries section: shared string table + per-type packed bodies // --------------------------------------------------------------------------- class StringTableBuilder { private map = new Map(); private list: string[] = []; /** Returns a u16 index for the string (0 reserved for "absent" sentinels). */ intern(value: string | undefined): number { if (!value) return 0; const existing = this.map.get(value); if (existing !== undefined) return existing; const idx = this.list.length + 1; // 1-indexed; 0 = absent if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW"); this.map.set(value, idx); this.list.push(value); return idx; } serialize(): Uint8Array { const enc = new TextEncoder(); const encoded = this.list.map((s) => enc.encode(s)); let total = 2; // u16 count for (const e of encoded) total += 2 + e.length; const out = new Uint8Array(total); const view = new DataView(out.buffer); let cursor = 0; view.setUint16(cursor, encoded.length, true); cursor += 2; for (const e of encoded) { if (e.length > 0xffff) throw new Error("STRING_TOO_LONG"); view.setUint16(cursor, e.length, true); cursor += 2; out.set(e, cursor); cursor += e.length; } return out; } } function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const count = view.getUint16(cursor, true); cursor += 2; const strings: string[] = []; const dec = new TextDecoder(); for (let i = 0; i < count; i++) { const len = view.getUint16(cursor, true); cursor += 2; strings.push(dec.decode(data.subarray(cursor, cursor + len))); cursor += len; } return { strings, cursor }; } function lookupString(strings: string[], idx: number): string | undefined { if (idx === 0) return undefined; return strings[idx - 1]; } function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array { const table = new StringTableBuilder(); const bodies: Uint8Array[] = []; // Pre-intern title for every entry so the table is built consistently. for (const entry of entries) table.intern(entry.title); for (const entry of entries) { bodies.push(encodeEntry(entry, table, formatVersion)); } const tableBytes = table.serialize(); // [u32 count][string_table][entries...] let bodiesLen = 0; for (const b of bodies) bodiesLen += b.length; const out = new Uint8Array(4 + tableBytes.length + bodiesLen); const view = new DataView(out.buffer); view.setUint32(0, entries.length, true); out.set(tableBytes, 4); let cursor = 4 + tableBytes.length; for (const b of bodies) { out.set(b, cursor); cursor += b.length; } return out; } function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const count = view.getUint32(0, true); const { strings, cursor: afterTable } = readStringTable(data, 4); const out: VaultEntry[] = []; let cursor = afterTable; for (let i = 0; i < count; i++) { const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion); out.push(entry); cursor = next; } return out; } // --------------------------------------------------------------------------- // Per-entry packing // --------------------------------------------------------------------------- function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array { const writer = new BinaryWriter(); const type = entryTypeByte(entry); writer.u8(type); writer.bytes(uuidToBytes(entry.id)); // common: created_at (seconds), updated_at_delta, deleted_at, title_idx const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000); const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000); const updatedDelta = Math.max(0, updatedSec - createdSec); const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0; // flags placeholder (we'll overwrite at the end) const flagOffset = writer.length; writer.u8(0); // v3+ flags2 placeholder, written for every entry so the section // layout stays uniform across types. let flags2Offset = -1; if (formatVersion >= FORMAT_V3) { flags2Offset = writer.length; writer.u8(0); } writer.u32(createdSec); writer.u32(updatedDelta); writer.u32(deletedSec); writer.u16(table.intern(entry.title)); let flags = 0; let flags2 = 0; if (entry.deletedAt) flags |= F_HAS_DELETED; if (type === ENTRY_TYPE_LOGIN) { writer.u16(table.intern(entry.url)); writer.u16(table.intern(entry.username)); writer.lpString(entry.password ?? "", formatVersion >= FORMAT_V4 ? 2 : 1); if (formatVersion >= FORMAT_V3 && entry.email) { flags2 |= F2_HAS_EMAIL; writer.lpString(entry.email, 1); } if (entry.totp) { flags |= F_HAS_TOTP; writer.lpString(entry.totp, 1); } if (entry.tags && entry.tags.length > 0) { flags |= F_HAS_TAGS; writer.u8(Math.min(entry.tags.length, 0xff)); for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) { writer.u16(table.intern(entry.tags[i])); } } if (entry.notes) { flags |= F_HAS_NOTES; writer.u16(table.intern(entry.notes)); } if (entry.canary) { flags |= F_HAS_CANARY; writer.u16(table.intern(entry.canary.webhookUrl)); writer.u16(table.intern(entry.canary.label)); } if (entry.passwordChangedAt) { flags |= F_HAS_PWCHANGED; const pwSec = Math.floor(entry.passwordChangedAt / 1000); writer.u32(Math.max(0, pwSec - createdSec)); } if (typeof entry.breach === "number" && entry.breach > 0) { flags |= F_HAS_BREACH; writer.u32(entry.breach); } if (entry.additionalUrls && entry.additionalUrls.length > 0) { flags |= F_HAS_ADDITIONAL_URLS; writer.u8(Math.min(entry.additionalUrls.length, 0xff)); for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) { writer.u16(table.intern(entry.additionalUrls[i])); } } } else if (type === ENTRY_TYPE_NOTE) { if (entry.notes) { flags |= F_HAS_NOTES; writer.u16(table.intern(entry.notes)); } if (entry.tags && entry.tags.length > 0) { flags |= F_HAS_TAGS; writer.u8(Math.min(entry.tags.length, 0xff)); for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) { writer.u16(table.intern(entry.tags[i])); } } } else if (type === ENTRY_TYPE_CARD) { writer.u16(table.intern(entry.cardholderName)); writer.lpString(entry.cardNumber ?? "", 1); writer.lpString(entry.expMonth ?? "", 1); writer.lpString(entry.expYear ?? "", 1); writer.lpString(entry.cvc ?? "", 1); writer.u16(table.intern(entry.cardNickname)); writer.u8(networkToByte(entry.network)); writer.u16(table.intern(entry.last4)); if (entry.tags && entry.tags.length > 0) { flags |= F_HAS_TAGS; writer.u8(Math.min(entry.tags.length, 0xff)); for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) { writer.u16(table.intern(entry.tags[i])); } } if (entry.notes) { flags |= F_HAS_NOTES; writer.u16(table.intern(entry.notes)); } } else if (type === ENTRY_TYPE_PASSKEY) { // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob. const pk = entry.passkey; if (!pk) throw new Error("INVALID_PASSKEY_ENTRY"); const blob = encodeJsonSection({ passkey: pk, // Preserve the full login field set for passkey-bearing login entries. ...({ ...(entry.password !== undefined ? { password: entry.password } : {}), ...(entry.url !== undefined ? { url: entry.url } : {}), ...(entry.username !== undefined ? { username: entry.username } : {}), ...(entry.email !== undefined ? { email: entry.email } : {}), ...(entry.totp !== undefined ? { totp: entry.totp } : {}), ...(entry.notes !== undefined ? { notes: entry.notes } : {}), ...(entry.tags !== undefined ? { tags: entry.tags } : {}), ...(entry.canary !== undefined ? { canary: entry.canary } : {}), ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}), ...(entry.breach !== undefined ? { breach: entry.breach } : {}), ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}), }), }); writer.u32(blob.length); writer.bytes(blob); } writer.setU8(flagOffset, flags); if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2); return writer.toBytes(); } function decodeEntry( data: Uint8Array, start: number, strings: string[], formatVersion: number ): { entry: VaultEntry; cursor: number } { const reader = new BinaryReader(data, start); const type = reader.u8(); const id = bytesToUuid(reader.read(16)); const flags = reader.u8(); const flags2 = formatVersion >= FORMAT_V3 ? reader.u8() : 0; const createdSec = reader.u32(); const updatedDelta = reader.u32(); const deletedSec = reader.u32(); const titleIdx = reader.u16(); const createdAt = createdSec * 1000; const modifiedAt = (createdSec + updatedDelta) * 1000; const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined; const title = lookupString(strings, titleIdx) ?? ""; const baseEntry: Partial = { id, title, createdAt, modifiedAt, ...(deletedAt !== undefined ? { deletedAt } : {}), }; if (type === ENTRY_TYPE_LOGIN) { const urlIdx = reader.u16(); const usernameIdx = reader.u16(); const password = reader.lpString(formatVersion >= FORMAT_V4 ? 2 : 1); const tail: Partial = {}; if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1); if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1); if (flags & F_HAS_TAGS) { const cnt = reader.u8(); const tags: string[] = []; for (let i = 0; i < cnt; i++) { const v = lookupString(strings, reader.u16()); if (v !== undefined) tags.push(v); } tail.tags = tags; } if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16()); if (flags & F_HAS_CANARY) { const webhookUrl = lookupString(strings, reader.u16()) ?? ""; const label = lookupString(strings, reader.u16()); const canary: Canary = { webhookUrl }; if (label !== undefined) canary.label = label; tail.canary = canary; } if (flags & F_HAS_PWCHANGED) { const pwDelta = reader.u32(); tail.passwordChangedAt = (createdSec + pwDelta) * 1000; } if (flags & F_HAS_BREACH) tail.breach = reader.u32(); if (flags & F_HAS_ADDITIONAL_URLS) { const cnt = reader.u8(); const urls: string[] = []; for (let i = 0; i < cnt; i++) { const v = lookupString(strings, reader.u16()); if (v !== undefined) urls.push(v); } tail.additionalUrls = urls; } return { entry: { ...(baseEntry as VaultEntry), type: "login", url: lookupString(strings, urlIdx), username: lookupString(strings, usernameIdx), password, ...tail, } as VaultEntry, cursor: reader.cursor, }; } if (type === ENTRY_TYPE_NOTE) { const tail: Partial = {}; if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16()); if (flags & F_HAS_TAGS) { const cnt = reader.u8(); const tags: string[] = []; for (let i = 0; i < cnt; i++) { const v = lookupString(strings, reader.u16()); if (v !== undefined) tags.push(v); } tail.tags = tags; } return { entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry, cursor: reader.cursor, }; } if (type === ENTRY_TYPE_CARD) { const cardholderIdx = reader.u16(); const cardNumber = reader.lpString(1); const expMonth = reader.lpString(1); const expYear = reader.lpString(1); const cvc = reader.lpString(1); const nicknameIdx = reader.u16(); const network = networkFromByte(reader.u8()); const last4Idx = reader.u16(); const tail: Partial = {}; if (flags & F_HAS_TAGS) { const cnt = reader.u8(); const tags: string[] = []; for (let i = 0; i < cnt; i++) { const v = lookupString(strings, reader.u16()); if (v !== undefined) tags.push(v); } tail.tags = tags; } if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16()); return { entry: { ...(baseEntry as VaultEntry), type: "card", cardholderName: lookupString(strings, cardholderIdx), cardNumber, expMonth, expYear, cvc, cardNickname: lookupString(strings, nicknameIdx), network, last4: lookupString(strings, last4Idx), ...tail, } as VaultEntry, cursor: reader.cursor, }; } if (type === ENTRY_TYPE_PASSKEY) { const blobLen = reader.u32(); const blob = reader.read(blobLen); const obj = decodeJsonSection>(blob); // MV-6: pick known passkey-bearing fields only — `...obj` let an // attacker-authored blob inject arbitrary keys past the section codec. // The full nested-shape validation happens in vault.ts // sanitizeRestoredEntry (every import path funnels through it); this // picker just stops unknown top-level keys at the decode boundary. const picked: Partial = {}; const allow = [ "passkey", "username", "email", "password", "url", "additionalUrls", "totp", "notes", "tags", "passwordChangedAt", "passwordHistory", "canary", "deletedAt", "breach", ] as const; for (const k of allow) { if (k in obj) (picked as Record)[k] = obj[k]; } return { entry: { ...(baseEntry as VaultEntry), type: "passkey", ...picked, } as VaultEntry, cursor: reader.cursor, }; } throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`); } function entryTypeByte(entry: VaultEntry): number { // Passkey sub-object takes precedence — the v2.11+ schema stores passkeys // as type:"login" entries with a nested passkey sub-object, so the type // field alone is not sufficient to detect them. if (entry.passkey) return ENTRY_TYPE_PASSKEY; switch (entry.type) { case "note": return ENTRY_TYPE_NOTE; case "card": return ENTRY_TYPE_CARD; case "passkey": return ENTRY_TYPE_PASSKEY; default: return ENTRY_TYPE_LOGIN; } } function networkToByte(network: CardNetwork | undefined): number { if (!network) return 0xff; const idx = NETWORK_VALUES.indexOf(network); return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown"); } function networkFromByte(b: number): CardNetwork | undefined { if (b === 0xff) return undefined; return NETWORK_VALUES[b] ?? "unknown"; } // --------------------------------------------------------------------------- // UUID packing — 16 raw bytes vs 36-character hyphenated string // --------------------------------------------------------------------------- export function uuidToBytes(uuid: string): Uint8Array { const hex = uuid.replace(/-/g, ""); if (hex.length !== 32) throw new Error("UUID_FORMAT"); const out = new Uint8Array(16); for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } export function bytesToUuid(bytes: Uint8Array): string { if (bytes.length !== 16) throw new Error("UUID_LENGTH"); const hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } // --------------------------------------------------------------------------- // Tiny binary reader/writer // --------------------------------------------------------------------------- class BinaryWriter { private chunks: Uint8Array[] = []; private len = 0; get length(): number { return this.len; } u8(v: number): void { const b = new Uint8Array(1); b[0] = v & 0xff; this.push(b); } setU8(offset: number, v: number): void { // Only used to back-patch the flags byte; always lands inside chunks[1] or // earlier. Reconstruct by walking chunks until we hit the offset. let cursor = 0; for (const c of this.chunks) { if (offset < cursor + c.length) { c[offset - cursor] = v & 0xff; return; } cursor += c.length; } throw new Error("setU8 out of range"); } u16(v: number): void { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, v & 0xffff, true); this.push(b); } u32(v: number): void { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v >>> 0, true); this.push(b); } bytes(b: Uint8Array): void { this.push(b); } /** Length-prefixed string. `lenSize` = 1 (u8) or 2 (u16). */ lpString(value: string, lenSize: 1 | 2): void { const enc = new TextEncoder().encode(value); if (lenSize === 1) { if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8"); this.u8(enc.length); } else { if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16"); this.u16(enc.length); } this.push(enc); } toBytes(): Uint8Array { const out = new Uint8Array(this.len); let cursor = 0; for (const c of this.chunks) { out.set(c, cursor); cursor += c.length; } return out; } private push(b: Uint8Array): void { this.chunks.push(b); this.len += b.length; } } // Exported for the PCH-02-008 bounds-check regression tests only — not part // of the payload API surface. export class BinaryReader { cursor: number; private view: DataView; constructor(private data: Uint8Array, start: number) { this.cursor = start; this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); } // PCH-02-008: fail truncated imported payloads with the decoder's typed // PAYLOAD_TOO_SHORT error, not undefined/NaN reads or engine RangeErrors. private ensure(n: number): void { if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT"); } u8(): number { this.ensure(1); return this.data[this.cursor++]; } u16(): number { this.ensure(2); const v = this.view.getUint16(this.cursor, true); this.cursor += 2; return v; } u32(): number { this.ensure(4); const v = this.view.getUint32(this.cursor, true); this.cursor += 4; return v; } read(n: number): Uint8Array { this.ensure(n); const out = this.data.subarray(this.cursor, this.cursor + n); this.cursor += n; return out; } lpString(lenSize: 1 | 2): string { const len = lenSize === 1 ? this.u8() : this.u16(); return new TextDecoder().decode(this.read(len)); } } // --------------------------------------------------------------------------- // Crypto helpers // --------------------------------------------------------------------------- async function deriveKey( passphrase: string, salt: Uint8Array, ): Promise { const pw = passphrase.normalize("NFC"); return argon2id({ password: pw, salt, parallelism: ARGON2_PARALLELISM, iterations: ARGON2_ITERATIONS, memorySize: ARGON2_MEMORY_KIB, hashLength: 32, outputType: "binary", }); } async function aesGcmEncrypt( key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array ): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> { const cryptoKey = await crypto.subtle.importKey( "raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"] ); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 }, cryptoKey, plaintext.buffer.slice( plaintext.byteOffset, plaintext.byteOffset + plaintext.byteLength ) as ArrayBuffer ); const out = new Uint8Array(raw); return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) }; } async function aesGcmDecrypt( key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array ): Promise { const cryptoKey = await crypto.subtle.importKey( "raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer, { name: "AES-GCM" }, false, ["decrypt"] ); const combined = concat([ciphertext, tag]); const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce.buffer.slice( nonce.byteOffset, nonce.byteOffset + nonce.byteLength ) as ArrayBuffer, tagLength: 128, }, cryptoKey, combined.buffer.slice( combined.byteOffset, combined.byteOffset + combined.byteLength ) as ArrayBuffer ); return new Uint8Array(raw); } async function blake3Truncated(data: Uint8Array): Promise { const hex = await blake3(data); // Take first CHECKSUM_LEN bytes of the BLAKE3 hash. const out = new Uint8Array(CHECKSUM_LEN); for (let i = 0; i < CHECKSUM_LEN; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } function concat(parts: Uint8Array[]): Uint8Array { let total = 0; for (const p of parts) total += p.length; const out = new Uint8Array(total); let cursor = 0; for (const p of parts) { out.set(p, cursor); cursor += p.length; } return out; }