cjkSerialise.ts
1/**2 * Full-fidelity vault serialisation for the CJK phrase backup (v3).3 *4 * v3 breaks with the earlier custom binary layout: the payload is now a5 * UTF-8 JSON document describing the complete vault — every entry with6 * every field the in-memory `VaultEntry` type carries, plus the user's7 * `VaultSettings`. The JSON is compressed with zstd (same as before) and8 * encrypted with AES-256-GCM (same as before), so on-the-wire size stays9 * competitive even though the plaintext is more verbose.10 *11 * Why the change: a CJK backup must be a *complete* Peach export. The12 * previous binary format silently dropped passkeys, canary webhooks,13 * tags, notes-typed entries, and all user settings. zstd on14 * well-structured JSON gets most of the compression back; correctness15 * and completeness are worth the extra kilobytes.16 */1718import type { VaultEntry } from "../shared/types";19import type { VaultSettings } from "../background/vault";2021/**22 * The top-level shape that lives inside the encrypted+compressed CJK23 * payload. `v: 3` is the format version — unrelated to `PAYLOAD_VERSION`24 * (the byte in the binary header), though they happen to match.25 */26export interface VaultBackupPayload {27 v: 3;28 entries: VaultEntry[];29 settings: VaultSettings;30}3132/**33 * Serialise the live vault (excluding tombstones — the caller filters) and34 * the current user settings into the bytes that will be zstd-compressed35 * and encrypted.36 */37export function serialiseVault(38 entries: VaultEntry[],39 settings: VaultSettings40): Uint8Array {41 const payload: VaultBackupPayload = {42 v: 3,43 entries: entries.map(stripEphemeral),44 settings: { ...settings },45 };46 return new TextEncoder().encode(JSON.stringify(payload));47}4849/**50 * Decode the bytes from a successful decrypt+decompress into a typed51 * payload. Throws `DESERIALISE_MALFORMED` for anything that isn't JSON52 * with the expected shape.53 */54export function deserialiseVault(bytes: Uint8Array): VaultBackupPayload {55 let parsed: unknown;56 try {57 parsed = JSON.parse(new TextDecoder().decode(bytes));58 } catch {59 throw new Error("DESERIALISE_MALFORMED");60 }6162 if (!parsed || typeof parsed !== "object") {63 throw new Error("DESERIALISE_MALFORMED");64 }6566 const candidate = parsed as Partial<VaultBackupPayload>;67 if (candidate.v !== 3 || !Array.isArray(candidate.entries)) {68 throw new Error("DESERIALISE_MALFORMED");69 }7071 const entries = candidate.entries.filter((e): e is VaultEntry =>72 Boolean(e && typeof e === "object" && typeof (e as VaultEntry).id === "string")73 );7475 const settings =76 candidate.settings && typeof candidate.settings === "object"77 ? (candidate.settings as VaultSettings)78 : {};7980 return { v: 3, entries, settings };81}8283/**84 * Strip fields that are ephemeral (derived from runtime state, not85 * authoritative) before writing them into a backup. Currently no fields86 * are stripped — `breach` counts, canary `lastPinged`/`pingCount`, and87 * `lastUsedAt` on passkeys are genuine vault data the user may want to88 * preserve across a round-trip. Kept as a seam for future removals.89 */90function stripEphemeral(entry: VaultEntry): VaultEntry {91 return entry;92}