cjkSerialise.ts

3.5 KB · raw

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 { PeachLicenseExport, 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  license?: PeachLicenseExport;31}3233export type { PeachLicenseExport };3435/**36 * Serialise the live vault (excluding tombstones — the caller filters) and37 * the current user settings into the bytes that will be zstd-compressed38 * and encrypted.39 */40export function serialiseVault(41  entries: VaultEntry[],42  settings: VaultSettings,43  license?: PeachLicenseExport44): Uint8Array {45  const payload: VaultBackupPayload = {46    v: 3,47    entries: entries.map(stripEphemeral),48    settings: { ...settings },49    ...(license ? { license } : {}),50  };51  return new TextEncoder().encode(JSON.stringify(payload));52}5354/**55 * Decode the bytes from a successful decrypt+decompress into a typed56 * payload. Throws `DESERIALISE_MALFORMED` for anything that isn't JSON57 * with the expected shape.58 */59export function deserialiseVault(bytes: Uint8Array): VaultBackupPayload {60  let parsed: unknown;61  try {62    parsed = JSON.parse(new TextDecoder().decode(bytes));63  } catch {64    throw new Error("DESERIALISE_MALFORMED");65  }6667  if (!parsed || typeof parsed !== "object") {68    throw new Error("DESERIALISE_MALFORMED");69  }7071  const candidate = parsed as Partial<VaultBackupPayload>;72  if (candidate.v !== 3 || !Array.isArray(candidate.entries)) {73    throw new Error("DESERIALISE_MALFORMED");74  }7576  const entries = candidate.entries.filter((e): e is VaultEntry =>77    Boolean(e && typeof e === "object" && typeof (e as VaultEntry).id === "string")78  );7980  const settings =81    candidate.settings && typeof candidate.settings === "object"82      ? (candidate.settings as VaultSettings)83      : {};8485  const license =86    candidate.license &&87    typeof candidate.license === "object" &&88    typeof (candidate.license as PeachLicenseExport).key === "string"89      ? (candidate.license as PeachLicenseExport)90      : undefined;9192  return { v: 3, entries, settings, ...(license ? { license } : {}) };93}9495/**96 * Strip fields that are ephemeral (derived from runtime state, not97 * authoritative) before writing them into a backup. Currently no fields98 * are stripped — `breach` counts, canary `lastPinged`/`pingCount`, and99 * `lastUsedAt` on passkeys are genuine vault data the user may want to100 * preserve across a round-trip. Kept as a seam for future removals.101 */102function stripEphemeral(entry: VaultEntry): VaultEntry {103  return entry;104}