/** * Full-fidelity vault serialisation for the CJK phrase backup (v3). * * v3 breaks with the earlier custom binary layout: the payload is now a * UTF-8 JSON document describing the complete vault — every entry with * every field the in-memory `VaultEntry` type carries, plus the user's * `VaultSettings`. The JSON is compressed with zstd (same as before) and * encrypted with AES-256-GCM (same as before), so on-the-wire size stays * competitive even though the plaintext is more verbose. * * Why the change: a CJK backup must be a *complete* Peach export. The * previous binary format silently dropped passkeys, canary webhooks, * tags, notes-typed entries, and all user settings. zstd on * well-structured JSON gets most of the compression back; correctness * and completeness are worth the extra kilobytes. */ import type { VaultEntry } from "../shared/types"; import type { VaultSettings } from "../background/vault"; /** * The top-level shape that lives inside the encrypted+compressed CJK * payload. `v: 3` is the format version — unrelated to `PAYLOAD_VERSION` * (the byte in the binary header), though they happen to match. */ export interface VaultBackupPayload { v: 3; entries: VaultEntry[]; settings: VaultSettings; } /** * Serialise the live vault (excluding tombstones — the caller filters) and * the current user settings into the bytes that will be zstd-compressed * and encrypted. */ export function serialiseVault( entries: VaultEntry[], settings: VaultSettings ): Uint8Array { const payload: VaultBackupPayload = { v: 3, entries: entries.map(stripEphemeral), settings: { ...settings }, }; return new TextEncoder().encode(JSON.stringify(payload)); } /** * Decode the bytes from a successful decrypt+decompress into a typed * payload. Throws `DESERIALISE_MALFORMED` for anything that isn't JSON * with the expected shape. */ export function deserialiseVault(bytes: Uint8Array): VaultBackupPayload { let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(bytes)); } catch { throw new Error("DESERIALISE_MALFORMED"); } if (!parsed || typeof parsed !== "object") { throw new Error("DESERIALISE_MALFORMED"); } const candidate = parsed as Partial; if (candidate.v !== 3 || !Array.isArray(candidate.entries)) { throw new Error("DESERIALISE_MALFORMED"); } const entries = candidate.entries.filter((e): e is VaultEntry => Boolean(e && typeof e === "object" && typeof (e as VaultEntry).id === "string") ); const settings = candidate.settings && typeof candidate.settings === "object" ? (candidate.settings as VaultSettings) : {}; return { v: 3, entries, settings }; } /** * Strip fields that are ephemeral (derived from runtime state, not * authoritative) before writing them into a backup. Currently no fields * are stripped — `breach` counts, canary `lastPinged`/`pingCount`, and * `lastUsedAt` on passkeys are genuine vault data the user may want to * preserve across a round-trip. Kept as a seam for future removals. */ function stripEphemeral(entry: VaultEntry): VaultEntry { return entry; }