// The durable Sync-generation record and its state machine (protocol §11). // // One record per installation, under a key no legacy build knows, so an older // build cannot overwrite it and a fresh install can tell itself apart from an // install that merely lost its identity. A missing or corrupt identity over any // existing trust is a reset condition, never a silent re-mint. // // The record carries no secret, so this module reads and writes it while the // vault is locked. Everything else it touches is injected through // [SyncGenerationIo], which keeps every transition — and every crash point // inside one — testable without Chrome. import { storageGet, storageSet } from "../shared/storage"; import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY } from "./pair-secrets"; import { PENDING_ADOPTION_HOLDER_KEY, PENDING_ADOPTION_IDENTITY_KEY, } from "./pending-adoption-holder"; import { REGISTRY_KEY, REVOCATIONS_KEY, listPairedDevices } from "./paired-devices"; export const GENERATION_KEY = "peach_sync_generation_v1"; const SCHEMA_VERSION = 1; const LEGACY_GENERATION = 1; const V2_GENERATION = 2; export const LEGACY_ACTIVE = "LEGACY_ACTIVE"; export const RESET_REQUIRED = "RESET_REQUIRED"; export const RESET_CONFIRMED = "RESET_CONFIRMED"; export const V2_ANCHOR = "V2_ANCHOR"; export const REPAIRING = "REPAIRING"; export const V2_ACTIVE = "V2_ACTIVE"; /** No record could be read or derived. Refuses sync rather than guessing. */ export const GENERATION_UNKNOWN = "UNKNOWN"; export type KnownGenerationState = | typeof LEGACY_ACTIVE | typeof RESET_REQUIRED | typeof RESET_CONFIRMED | typeof V2_ANCHOR | typeof REPAIRING | typeof V2_ACTIVE; export type GenerationState = KnownGenerationState | typeof GENERATION_UNKNOWN; export type SyncGeneration = { schemaVersion: number; generation: number; state: GenerationState; anchorDeviceId?: string; legacyArchiveRef?: string; createdAt: number; }; /** * `previous` is retained so a rewrite of the active record can be validated * against what it replaces, and so a crash on either side of that write leaves * a recoverable record (§11.2, seal before switch). */ export type SyncGenerationBlob = { schemaVersion: number; active: SyncGeneration; previous?: SyncGeneration; }; export type SyncDecision = "allowed" | "reset_required" | "sync_generation_unknown"; export type ResetResult = "V2_ANCHOR" | "reset_not_completed"; /** * The storage boundary. The trust probe is split per class so a test can assert * the refusal for each one, and archival and clearing are separate steps so a * crash between them is injectable. */ export interface SyncGenerationIo { readBlob(): Promise; writeBlob(blob: SyncGenerationBlob): Promise; hasPairedDevices(): Promise; hasRevocations(): Promise; hasPendingHolders(): Promise; hasLegacyRouteMaterial(): Promise; archiveLegacyState(generation: number): Promise; clearLegacyTrustState(): Promise; ensureV2Identity(): Promise; getDeviceId(): Promise; } const STATE_NAMES: Record = { LEGACY_ACTIVE: true, RESET_REQUIRED: true, RESET_CONFIRMED: true, V2_ANCHOR: true, REPAIRING: true, V2_ACTIVE: true, }; export function isGenerationState(value: unknown): value is KnownGenerationState { return typeof value === "string" && STATE_NAMES[value] === true; } export function encodeGeneration(blob: SyncGenerationBlob): string { return JSON.stringify(blob); } /** * A record this build cannot read is not a record: another schema, an * unrecognised state, or a nonsensical generation all resolve to null, so the * caller refuses rather than misreading a newer generation as legacy trust. */ export function decodeGeneration(value: unknown): SyncGenerationBlob | null { try { const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value; if (!parsed || typeof parsed !== "object") return null; const blob = parsed as Partial; if (blob.schemaVersion !== SCHEMA_VERSION) return null; const active = blob.active as Partial | undefined; if (!active || !isGenerationState(active.state)) return null; if (!Number.isInteger(active.generation)) return null; if ((active.generation ?? 0) < LEGACY_GENERATION) return null; return blob as SyncGenerationBlob; } catch { return null; } } export async function hasLegacyTrustState(io: SyncGenerationIo): Promise { const [paired, revocations, pending, route] = await Promise.all([ io.hasPairedDevices(), io.hasRevocations(), io.hasPendingHolders(), io.hasLegacyRouteMaterial(), ]); return paired || revocations || pending || route; } /** * Read the persisted record, or derive one when none has ever been written. A * derived record that cannot be persisted is not trusted: the caller must not * act on a generation this installation may not actually hold. */ export async function resolveGeneration( io: SyncGenerationIo, nowMs: number = Date.now(), ): Promise { const unknown: SyncGeneration = { schemaVersion: SCHEMA_VERSION, generation: LEGACY_GENERATION, state: GENERATION_UNKNOWN, createdAt: nowMs, }; const raw = await io.readBlob(); if (raw !== null && raw !== undefined) { return decodeGeneration(raw)?.active ?? unknown; } const hasTrust = await hasLegacyTrustState(io); const derived: SyncGeneration = { schemaVersion: SCHEMA_VERSION, generation: hasTrust ? LEGACY_GENERATION : V2_GENERATION, state: hasTrust ? LEGACY_ACTIVE : V2_ACTIVE, createdAt: nowMs, }; return (await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: derived })) ? derived : unknown; } /** * Enter the state that blocks sync and states the migration. Reached by the * user asking for a reset, and by the create-on-miss refusal. Idempotent, and * never moves a device that has already reset back into the legacy generation. */ export async function enterResetRequired( io: SyncGenerationIo, nowMs: number = Date.now(), ): Promise { const current = await resolveGeneration(io, nowMs); if (current.state !== LEGACY_ACTIVE && current.state !== RESET_REQUIRED) return current; const next: SyncGeneration = { ...current, state: RESET_REQUIRED }; await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current }); return next; } /** * Archive the legacy generation, mint the v2 identity, then flip the pointer — * in that order, so every crash point leaves either a recoverable * RESET_CONFIRMED or a committed V2_ANCHOR, and never a lost archive (§11.3). * * The caller has already confirmed the master password. `checkpoint` fires * immediately before each step that can fail; a test throws from it to inject a * crash at exactly that point. */ export async function confirmSecureReset( io: SyncGenerationIo, opts: { nowMs: number; checkpoint?: (name: string) => void }, ): Promise { const { nowMs } = opts; const checkpoint = opts.checkpoint ?? (() => {}); const current = await resolveGeneration(io, nowMs); if ( current.state !== LEGACY_ACTIVE && current.state !== RESET_REQUIRED && current.state !== RESET_CONFIRMED ) { return "reset_not_completed"; } const persist = (state: SyncGeneration): Promise => io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: state, previous: current }); checkpoint("before-confirmed"); if (!(await persist({ ...current, state: RESET_CONFIRMED }))) return "reset_not_completed"; checkpoint("before-archive"); const archiveRef = await io.archiveLegacyState(current.generation); if (!archiveRef) return "reset_not_completed"; checkpoint("after-archive"); checkpoint("before-clear"); if (!(await io.clearLegacyTrustState())) return "reset_not_completed"; checkpoint("after-clear"); checkpoint("before-identity"); if (!(await io.ensureV2Identity())) return "reset_not_completed"; checkpoint("after-identity"); // The identity exists by now; a read failure here is not worth abandoning a // reset whose archive is already durable. const anchorDeviceId = await io.getDeviceId().catch(() => undefined); const next: SyncGeneration = { schemaVersion: SCHEMA_VERSION, generation: current.generation + 1, state: V2_ANCHOR, anchorDeviceId, legacyArchiveRef: archiveRef, createdAt: nowMs, }; checkpoint("before-generation-write"); if (!(await io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current }))) { return "reset_not_completed"; } checkpoint("after-generation-write"); return "V2_ANCHOR"; } /** An anchored device stops being "not re-paired" once a direct v2 peer exists. */ export async function onDirectV2PairingEstablished( io: SyncGenerationIo, hasDirectDevice: boolean, nowMs: number = Date.now(), ): Promise { const current = await resolveGeneration(io, nowMs); if (current.state !== V2_ANCHOR && current.state !== REPAIRING) return; const next: SyncGeneration = { ...current, state: hasDirectDevice ? V2_ACTIVE : REPAIRING }; await io .writeBlob({ schemaVersion: SCHEMA_VERSION, active: next, previous: current }) .catch(() => false); } /** Consulted before a sync session or an adoption grant is opened. */ export async function assertSyncAllowed( io: SyncGenerationIo, nowMs: number = Date.now(), ): Promise { const current = await resolveGeneration(io, nowMs); if (current.state === RESET_REQUIRED || current.state === RESET_CONFIRMED) return "reset_required"; return isGenerationState(current.state) ? "allowed" : "sync_generation_unknown"; } /** True once a reset has retired the legacy generation. */ export async function isPostReset( io: SyncGenerationIo, nowMs: number = Date.now(), ): Promise { const current = await resolveGeneration(io, nowMs); return current.state === V2_ANCHOR || current.state === REPAIRING || current.state === V2_ACTIVE; } /** * Whether a missing or corrupt identity may be minted (§11.5). A missing * identity is only mintable on a genuinely fresh installation, never over any * trust this device already holds. */ export async function mintDecision( io: SyncGenerationIo, nowMs: number = Date.now(), ): Promise<"MINT" | "REFUSE_RESET_REQUIRED"> { if (!(await hasLegacyTrustState(io))) return "MINT"; await enterResetRequired(io, nowMs); return "REFUSE_RESET_REQUIRED"; } /** * Minting and reading the device identity, supplied by `./device-identity` the * same way `vault.ts` receives its identity hooks. Injecting it keeps this * module free of device-identity and therefore free of an import cycle: the * identity loader is a consumer of the generation state, not the reverse. */ export type GenerationIdentity = { /** True once a usable current identity exists; may mint at RESET_CONFIRMED. */ ensureIdentity(): Promise; deviceId(): Promise; }; let generationIdentity: GenerationIdentity | null = null; export function setGenerationIdentity(identity: GenerationIdentity): void { generationIdentity = identity; } function emptyList(value: unknown): boolean { return !Array.isArray(value) || value.length === 0; } function emptyRecord(value: unknown): boolean { return !value || typeof value !== "object" || Object.keys(value as object).length === 0; } export const browserGenerationIo: SyncGenerationIo = { async readBlob() { return (await chrome.storage.local.get(GENERATION_KEY))[GENERATION_KEY]; }, async writeBlob(blob) { const encoded = encodeGeneration(blob); try { await storageSet(GENERATION_KEY, encoded); // Read back rather than trusting the set: a successful write is what // licenses retiring the legacy generation. return (await storageGet(GENERATION_KEY)) === encoded; } catch { return false; } }, async hasPairedDevices() { return (await listPairedDevices()).length > 0; }, async hasRevocations() { return !emptyList((await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY]); }, async hasPendingHolders() { const stored = await chrome.storage.local.get([ PENDING_ADOPTION_HOLDER_KEY, PENDING_ADOPTION_IDENTITY_KEY, ]); return !!stored[PENDING_ADOPTION_HOLDER_KEY] || !!stored[PENDING_ADOPTION_IDENTITY_KEY]; }, async hasLegacyRouteMaterial() { return !emptyRecord((await chrome.storage.local.get(PAIR_SECRETS_KEY))[PAIR_SECRETS_KEY]); }, /** * Copy the legacy trust material verbatim into an archive tagged with its * generation. The registry, the ledger and the pair secrets are exactly what * §11.4 retains; the generation is tagged both in the key and in the record, * so a rollback build can tell which generation it is looking at. */ async archiveLegacyState(generation) { const ref = `peach_sync_archive_g${generation}_v1`; try { const stored = await chrome.storage.local.get([ REGISTRY_KEY, REVOCATIONS_KEY, PENDING_ADOPTION_HOLDER_KEY, PENDING_ADOPTION_IDENTITY_KEY, PAIR_SECRETS_KEY, ]); const archive = { schemaVersion: 1, generation, archivedAt: Date.now(), pairedDevices: stored[REGISTRY_KEY] ?? null, revocations: stored[REVOCATIONS_KEY] ?? null, pendingHolder: stored[PENDING_ADOPTION_HOLDER_KEY] ?? null, pendingIdentity: stored[PENDING_ADOPTION_IDENTITY_KEY] ?? null, pairSecrets: stored[PAIR_SECRETS_KEY] ?? null, }; await chrome.storage.local.set({ [ref]: archive }); const written = (await chrome.storage.local.get(ref))[ref]; return JSON.stringify(written) === JSON.stringify(archive) ? ref : null; } catch { return null; } }, async clearLegacyTrustState() { try { await chrome.storage.local.remove([ REGISTRY_KEY, REVOCATIONS_KEY, PENDING_ADOPTION_HOLDER_KEY, PENDING_ADOPTION_IDENTITY_KEY, PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY, ]); // Verified rather than assumed: the clear is what licenses the mint. const [paired, revocations, pending, route] = await Promise.all([ browserGenerationIo.hasPairedDevices(), browserGenerationIo.hasRevocations(), browserGenerationIo.hasPendingHolders(), browserGenerationIo.hasLegacyRouteMaterial(), ]); return !paired && !revocations && !pending && !route; } catch { return false; } }, async ensureV2Identity() { return generationIdentity ? generationIdentity.ensureIdentity() : false; }, async getDeviceId() { if (!generationIdentity) throw new Error("generation_identity_unconfigured"); return generationIdentity.deviceId(); }, };