12345678910111213import { storageGet, storageSet } from "../shared/storage";14import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY } 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 } 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";333435export 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};55565758596061export 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";70717273747576export 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}105106107108109110111export 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}136137138139140141142export 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}167168169170171172173export 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}183184185186187188189190191192193export 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 228 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}245246247export 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}259260261export 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}269270271export 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}278279280281282283284export 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}292293294295296297298299export type GenerationIdentity = {300 301 ensureIdentity(): Promise<boolean>;302 deviceId(): Promise<string>;303};304305let generationIdentity: GenerationIdentity | null = null;306307export function setGenerationIdentity(identity: GenerationIdentity): void {308 generationIdentity = identity;309}310311function emptyList(value: unknown): boolean {312 return !Array.isArray(value) || value.length === 0;313}314315function emptyRecord(value: unknown): boolean {316 return !value || typeof value !== "object" || Object.keys(value as object).length === 0;317}318319export const browserGenerationIo: SyncGenerationIo = {320 async readBlob() {321 return (await chrome.storage.local.get(GENERATION_KEY))[GENERATION_KEY];322 },323324 async writeBlob(blob) {325 const encoded = encodeGeneration(blob);326 try {327 await storageSet(GENERATION_KEY, encoded);328 329 330 return (await storageGet(GENERATION_KEY)) === encoded;331 } catch {332 return false;333 }334 },335336 async hasPairedDevices() {337 return (await listPairedDevices()).length > 0;338 },339340 async hasRevocations() {341 return !emptyList((await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY]);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 357358359360361362 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 SESSION_PAIR_SECRETS_KEY,399 ]);400 401 const [paired, revocations, pending, route] = await Promise.all([402 browserGenerationIo.hasPairedDevices(),403 browserGenerationIo.hasRevocations(),404 browserGenerationIo.hasPendingHolders(),405 browserGenerationIo.hasLegacyRouteMaterial(),406 ]);407 return !paired && !revocations && !pending && !route;408 } catch {409 return false;410 }411 },412413 async ensureV2Identity() {414 return generationIdentity ? generationIdentity.ensureIdentity() : false;415 },416417 async getDeviceId() {418 if (!generationIdentity) throw new Error("generation_identity_unconfigured");419 return generationIdentity.deviceId();420 },421};