sync-generation.ts

14.8 KB · raw

1// The durable Sync-generation record and its state machine (protocol §11).2//3// One record per installation, under a key no legacy build knows, so an older4// build cannot overwrite it and a fresh install can tell itself apart from an5// install that merely lost its identity. A missing or corrupt identity over any6// existing trust is a reset condition, never a silent re-mint.7//8// The record carries no secret, so this module reads and writes it while the9// vault is locked. Everything else it touches is injected through10// [SyncGenerationIo], which keeps every transition — and every crash point11// inside one — testable without Chrome.1213import { storageGet, storageSet } from "../shared/storage";14import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY, dropSessionCache } from "./pair-secrets";15import {16  PENDING_ADOPTION_HOLDER_KEY,17  PENDING_ADOPTION_IDENTITY_KEY,18} from "./pending-adoption-holder";19import { REGISTRY_KEY, REVOCATIONS_KEY, listPairedDevices, TrustDataCorruptedError } from "./paired-devices";2021export const GENERATION_KEY = "peach_sync_generation_v1";2223const SCHEMA_VERSION = 1;24const LEGACY_GENERATION = 1;25const V2_GENERATION = 2;2627export const LEGACY_ACTIVE = "LEGACY_ACTIVE";28export const RESET_REQUIRED = "RESET_REQUIRED";29export const RESET_CONFIRMED = "RESET_CONFIRMED";30export const V2_ANCHOR = "V2_ANCHOR";31export const REPAIRING = "REPAIRING";32export const V2_ACTIVE = "V2_ACTIVE";3334/** No record could be read or derived. Refuses sync rather than guessing. */35export const GENERATION_UNKNOWN = "UNKNOWN";3637export type KnownGenerationState =38  | typeof LEGACY_ACTIVE39  | typeof RESET_REQUIRED40  | typeof RESET_CONFIRMED41  | typeof V2_ANCHOR42  | typeof REPAIRING43  | typeof V2_ACTIVE;4445export type GenerationState = KnownGenerationState | typeof GENERATION_UNKNOWN;4647export type SyncGeneration = {48  schemaVersion: number;49  generation: number;50  state: GenerationState;51  anchorDeviceId?: string;52  legacyArchiveRef?: string;53  createdAt: number;54};5556/**57 * `previous` is retained so a rewrite of the active record can be validated58 * against what it replaces, and so a crash on either side of that write leaves59 * a recoverable record (§11.2, seal before switch).60 */61export type SyncGenerationBlob = {62  schemaVersion: number;63  active: SyncGeneration;64  previous?: SyncGeneration;65};6667export type SyncDecision = "allowed" | "reset_required" | "sync_generation_unknown";6869export type ResetResult = "V2_ANCHOR" | "reset_not_completed";7071/**72 * The storage boundary. The trust probe is split per class so a test can assert73 * the refusal for each one, and archival and clearing are separate steps so a74 * crash between them is injectable.75 */76export interface SyncGenerationIo {77  readBlob(): Promise<unknown>;78  writeBlob(blob: SyncGenerationBlob): Promise<boolean>;79  hasPairedDevices(): Promise<boolean>;80  hasRevocations(): Promise<boolean>;81  hasPendingHolders(): Promise<boolean>;82  hasLegacyRouteMaterial(): Promise<boolean>;83  archiveLegacyState(generation: number): Promise<string | null>;84  clearLegacyTrustState(): Promise<boolean>;85  ensureV2Identity(): Promise<boolean>;86  getDeviceId(): Promise<string>;87}8889const STATE_NAMES: Record<string, true> = {90  LEGACY_ACTIVE: true,91  RESET_REQUIRED: true,92  RESET_CONFIRMED: true,93  V2_ANCHOR: true,94  REPAIRING: true,95  V2_ACTIVE: true,96};9798export function isGenerationState(value: unknown): value is KnownGenerationState {99  return typeof value === "string" && STATE_NAMES[value] === true;100}101102export function encodeGeneration(blob: SyncGenerationBlob): string {103  return JSON.stringify(blob);104}105106/**107 * A record this build cannot read is not a record: another schema, an108 * unrecognised state, or a nonsensical generation all resolve to null, so the109 * caller refuses rather than misreading a newer generation as legacy trust.110 */111export function decodeGeneration(value: unknown): SyncGenerationBlob | null {112  try {113    const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value;114    if (!parsed || typeof parsed !== "object") return null;115    const blob = parsed as Partial<SyncGenerationBlob>;116    if (blob.schemaVersion !== SCHEMA_VERSION) return null;117    const active = blob.active as Partial<SyncGeneration> | undefined;118    if (!active || !isGenerationState(active.state)) return null;119    if (!Number.isInteger(active.generation)) return null;120    if ((active.generation ?? 0) < LEGACY_GENERATION) return null;121    return blob as SyncGenerationBlob;122  } catch {123    return null;124  }125}126127export async function hasLegacyTrustState(io: SyncGenerationIo): Promise<boolean> {128  const [paired, revocations, pending, route] = await Promise.all([129    io.hasPairedDevices(),130    io.hasRevocations(),131    io.hasPendingHolders(),132    io.hasLegacyRouteMaterial(),133  ]);134  return paired || revocations || pending || route;135}136137/**138 * Read the persisted record, or derive one when none has ever been written. A139 * derived record that cannot be persisted is not trusted: the caller must not140 * act on a generation this installation may not actually hold.141 */142export async function resolveGeneration(143  io: SyncGenerationIo,144  nowMs: number = Date.now(),145): Promise<SyncGeneration> {146  const unknown: SyncGeneration = {147    schemaVersion: SCHEMA_VERSION,148    generation: LEGACY_GENERATION,149    state: GENERATION_UNKNOWN,150    createdAt: nowMs,151  };152  const raw = await io.readBlob();153  if (raw !== null && raw !== undefined) {154    return decodeGeneration(raw)?.active ?? unknown;155  }156  const hasTrust = await hasLegacyTrustState(io);157  const derived: SyncGeneration = {158    schemaVersion: SCHEMA_VERSION,159    generation: hasTrust ? LEGACY_GENERATION : V2_GENERATION,160    state: hasTrust ? LEGACY_ACTIVE : V2_ACTIVE,161    createdAt: nowMs,162  };163  return (await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: derived }))164    ? derived165    : unknown;166}167168/**169 * Enter the state that blocks sync and states the migration. Reached by the170 * user asking for a reset, and by the create-on-miss refusal. Idempotent, and171 * never moves a device that has already reset back into the legacy generation.172 */173export async function enterResetRequired(174  io: SyncGenerationIo,175  nowMs: number = Date.now(),176): Promise<SyncGeneration> {177  const current = await resolveGeneration(io, nowMs);178  if (current.state !== LEGACY_ACTIVE && current.state !== RESET_REQUIRED) return current;179  const next: SyncGeneration = { ...current, state: RESET_REQUIRED };180  await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current });181  return next;182}183184/**185 * Archive the legacy generation, mint the v2 identity, then flip the pointer —186 * in that order, so every crash point leaves either a recoverable187 * RESET_CONFIRMED or a committed V2_ANCHOR, and never a lost archive (§11.3).188 *189 * The caller has already confirmed the master password. `checkpoint` fires190 * immediately before each step that can fail; a test throws from it to inject a191 * crash at exactly that point.192 */193export async function confirmSecureReset(194  io: SyncGenerationIo,195  opts: { nowMs: number; checkpoint?: (name: string) => void },196): Promise<ResetResult> {197  const { nowMs } = opts;198  const checkpoint = opts.checkpoint ?? (() => {});199  const current = await resolveGeneration(io, nowMs);200  if (201    current.state !== LEGACY_ACTIVE &&202    current.state !== RESET_REQUIRED &&203    current.state !== RESET_CONFIRMED204  ) {205    return "reset_not_completed";206  }207208  const persist = (state: SyncGeneration): Promise<boolean> =>209    io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: state, previous: current });210211  checkpoint("before-confirmed");212  if (!(await persist({ ...current, state: RESET_CONFIRMED }))) return "reset_not_completed";213214  checkpoint("before-archive");215  const archiveRef = await io.archiveLegacyState(current.generation);216  if (!archiveRef) return "reset_not_completed";217  checkpoint("after-archive");218219  checkpoint("before-clear");220  if (!(await io.clearLegacyTrustState())) return "reset_not_completed";221  checkpoint("after-clear");222223  checkpoint("before-identity");224  if (!(await io.ensureV2Identity())) return "reset_not_completed";225  checkpoint("after-identity");226227  // The identity exists by now; a read failure here is not worth abandoning a228  // reset whose archive is already durable.229  const anchorDeviceId = await io.getDeviceId().catch(() => undefined);230  const next: SyncGeneration = {231    schemaVersion: SCHEMA_VERSION,232    generation: current.generation + 1,233    state: V2_ANCHOR,234    anchorDeviceId,235    legacyArchiveRef: archiveRef,236    createdAt: nowMs,237  };238  checkpoint("before-generation-write");239  if (!(await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current }))) {240    return "reset_not_completed";241  }242  checkpoint("after-generation-write");243  return "V2_ANCHOR";244}245246/** An anchored device stops being "not re-paired" once a direct v2 peer exists. */247export async function onDirectV2PairingEstablished(248  io: SyncGenerationIo,249  hasDirectDevice: boolean,250  nowMs: number = Date.now(),251): Promise<void> {252  const current = await resolveGeneration(io, nowMs);253  if (current.state !== V2_ANCHOR && current.state !== REPAIRING) return;254  const next: SyncGeneration = { ...current, state: hasDirectDevice ? V2_ACTIVE : REPAIRING };255  await io256    .writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current })257    .catch(() => false);258}259260/** Consulted before a sync session or an adoption grant is opened. */261export async function assertSyncAllowed(262  io: SyncGenerationIo,263  nowMs: number = Date.now(),264): Promise<SyncDecision> {265  const current = await resolveGeneration(io, nowMs);266  if (current.state === RESET_REQUIRED || current.state === RESET_CONFIRMED) return "reset_required";267  return isGenerationState(current.state) ? "allowed" : "sync_generation_unknown";268}269270/** True once a reset has retired the legacy generation. */271export async function isPostReset(272  io: SyncGenerationIo,273  nowMs: number = Date.now(),274): Promise<boolean> {275  const current = await resolveGeneration(io, nowMs);276  return current.state === V2_ANCHOR || current.state === REPAIRING || current.state === V2_ACTIVE;277}278279/**280 * Whether a missing or corrupt identity may be minted (§11.5). A missing281 * identity is only mintable on a genuinely fresh installation, never over any282 * trust this device already holds.283 */284export async function mintDecision(285  io: SyncGenerationIo,286  nowMs: number = Date.now(),287): Promise<"MINT" | "REFUSE_RESET_REQUIRED"> {288  if (!(await hasLegacyTrustState(io))) return "MINT";289  await enterResetRequired(io, nowMs);290  return "REFUSE_RESET_REQUIRED";291}292293/**294 * Minting and reading the device identity, supplied by `./device-identity` the295 * same way `vault.ts` receives its identity hooks. Injecting it keeps this296 * module free of device-identity and therefore free of an import cycle: the297 * identity loader is a consumer of the generation state, not the reverse.298 */299export type GenerationIdentity = {300  /** True once a usable current identity exists; may mint at RESET_CONFIRMED. */301  ensureIdentity(): Promise<boolean>;302  deviceId(): Promise<string>;303};304305let generationIdentity: GenerationIdentity | null = null;306307export function setGenerationIdentity(identity: GenerationIdentity): void {308  generationIdentity = identity;309}310311312function emptyRecord(value: unknown): boolean {313  return !value || typeof value !== "object" || Object.keys(value as object).length === 0;314}315316export const browserGenerationIo: SyncGenerationIo = {317  async readBlob() {318    return (await chrome.storage.local.get(GENERATION_KEY))[GENERATION_KEY];319  },320321  async writeBlob(blob) {322    const encoded = encodeGeneration(blob);323    try {324      await storageSet(GENERATION_KEY, encoded);325      // Read back rather than trusting the set: a successful write is what326      // licenses retiring the legacy generation.327      return (await storageGet(GENERATION_KEY)) === encoded;328    } catch {329      return false;330    }331  },332333  async hasPairedDevices() {334    return (await listPairedDevices()).length > 0;335  },336337  async hasRevocations() {338    const value = (await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY];339    if (value === undefined) return false;340    if (!Array.isArray(value)) throw new TrustDataCorruptedError(REVOCATIONS_KEY, `expected array, got ${typeof value}`);341    return value.length > 0;342  },343344  async hasPendingHolders() {345    const stored = await chrome.storage.local.get([346      PENDING_ADOPTION_HOLDER_KEY,347      PENDING_ADOPTION_IDENTITY_KEY,348    ]);349    return !!stored[PENDING_ADOPTION_HOLDER_KEY] || !!stored[PENDING_ADOPTION_IDENTITY_KEY];350  },351352  async hasLegacyRouteMaterial() {353    return !emptyRecord((await chrome.storage.local.get(PAIR_SECRETS_KEY))[PAIR_SECRETS_KEY]);354  },355356  /**357   * Copy the legacy trust material verbatim into an archive tagged with its358   * generation. The registry, the ledger and the pair secrets are exactly what359   * §11.4 retains; the generation is tagged both in the key and in the record,360   * so a rollback build can tell which generation it is looking at.361   */362  async archiveLegacyState(generation) {363    const ref = `peach_sync_archive_g${generation}_v1`;364    try {365      const stored = await chrome.storage.local.get([366        REGISTRY_KEY,367        REVOCATIONS_KEY,368        PENDING_ADOPTION_HOLDER_KEY,369        PENDING_ADOPTION_IDENTITY_KEY,370        PAIR_SECRETS_KEY,371      ]);372      const archive = {373        schemaVersion: 1,374        generation,375        archivedAt: Date.now(),376        pairedDevices: stored[REGISTRY_KEY] ?? null,377        revocations: stored[REVOCATIONS_KEY] ?? null,378        pendingHolder: stored[PENDING_ADOPTION_HOLDER_KEY] ?? null,379        pendingIdentity: stored[PENDING_ADOPTION_IDENTITY_KEY] ?? null,380        pairSecrets: stored[PAIR_SECRETS_KEY] ?? null,381      };382      await chrome.storage.local.set({ [ref]: archive });383      const written = (await chrome.storage.local.get(ref))[ref];384      return JSON.stringify(written) === JSON.stringify(archive) ? ref : null;385    } catch {386      return null;387    }388  },389390  async clearLegacyTrustState() {391    try {392      await chrome.storage.local.remove([393        REGISTRY_KEY,394        REVOCATIONS_KEY,395        PENDING_ADOPTION_HOLDER_KEY,396        PENDING_ADOPTION_IDENTITY_KEY,397        PAIR_SECRETS_KEY,398      ]);399      await dropSessionCache();400      // Verified rather than assumed: the clear is what licenses the mint.401      const [paired, revocations, pending, route, session] = await Promise.all([402        browserGenerationIo.hasPairedDevices(),403        browserGenerationIo.hasRevocations(),404        browserGenerationIo.hasPendingHolders(),405        browserGenerationIo.hasLegacyRouteMaterial(),406        chrome.storage.session.get(SESSION_PAIR_SECRETS_KEY),407      ]);408      return !paired && !revocations && !pending && !route && session[SESSION_PAIR_SECRETS_KEY] === undefined;409    } catch {410      return false;411    }412  },413414  async ensureV2Identity() {415    return generationIdentity ? generationIdentity.ensureIdentity() : false;416  },417418  async getDeviceId() {419    if (!generationIdentity) throw new Error("generation_identity_unconfigured");420    return generationIdentity.deviceId();421  },422};