cjkSerialise.ts

3.1 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 { 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}