80bf58c56bfc
implement the secure reset generation state machine
- time
- 2026-09-10 22:55 UTC · 1 month ago
- machine
- koh2
- agent
- deepseek/deepseek-v4-flash
- session
- secure sync reset migration
- parent
- 7da5fef92c4c
↓ peach-browser-80bf58c5.tar.gzatlas at this save
files changed22 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/device-identity.ts+35 −0src/background/message-policy.test.ts+1 −1src/background/message-policy.ts+2 −0src/background/messages.test.ts+4 −0src/background/messages.ts+36 −0src/background/pair-secrets.ts+10 −10src/background/paired-devices.ts+2 −2src/background/relay-adoption-bg.test.ts+77 −0src/background/relay-adoption-bg.ts+21 −0src/background/sync-generation.test.ts+385 −0src/background/sync-generation.ts+421 −0src/background/sync-runner.test.ts+4 −0src/background/sync-trigger.test.ts+60 −0src/background/sync-trigger.ts+9 −0src/background/sync-vault-adapter.test.ts+4 −0src/popup/views/Settings.tsx+146 −0src/shared/sync-outcome.test.ts+3 −0src/shared/sync-outcome.ts+9 −0src/shared/types.ts+2 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.40",4+ "version": "5.2.41",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.2.40",5+ "version": "5.2.41",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.40",3+ "version": "5.2.41",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/device-identity.ts+35 −0
@@ -32,6 +32,12 @@3232 type IdentityKeyEnvelope,3333 type SealedBox,3434 } from "./vault";35+import {36+ browserGenerationIo,37+ enterResetRequired,38+ hasLegacyTrustState,39+ setGenerationIdentity,40+} from "./sync-generation";3541 3642 const DEVICE_NAME_KEY = "peach_device_name";3743 const IDENTITY_RECORD_KEY = "peach_local_identity_v4";@@ -215,6 +221,31 @@215221 });216222 217223 /**224+ * The secure reset's identity step (§11.3). Only reached at RESET_CONFIRMED,225+ * after the legacy trust has been archived and cleared, so minting here is the226+ * anchored device's own deliberate action rather than a silent replacement. The227+ * guard still refuses to mint over live trust, which is what would otherwise228+ * turn the create-on-miss refusal into a bypass.229+ */230+setGenerationIdentity({231+ async ensureIdentity() {232+ try {233+ await buildLocalIdentity();234+ return true;235+ } catch {236+ if (await hasLegacyTrustState(browserGenerationIo)) return false;237+ try {238+ await rotateDeviceId();239+ return true;240+ } catch {241+ return false;242+ }243+ }244+ },245+ deviceId: getOrCreateDeviceId,246+});247+248+/**218249 * A legacy random record is completed in place -- same device ID and keys --219250 * rather than regenerated. `createdAt` is the upgrade time, because the original220251 * creation time was never recorded.@@ -364,6 +395,10 @@364395 // rather than hand every peer an unknown device whose new keys no longer365396 // match the revocation ledger.366397 if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {398+ throw new Error("identity_migration_required");399+ }400+ if (await hasLegacyTrustState(browserGenerationIo)) {401+ await enterResetRequired(browserGenerationIo);367402 throw new Error("identity_migration_required");368403 }369404 const record = await makeRecord();src/background/message-policy.test.ts+1 −1
@@ -23,7 +23,7 @@2323 "GET_SITE_PASSWORD_POLICY", "GET_LOCAL_IDENTITY", "LIST_PAIRED_DEVICES", "RENAME_PAIRED_DEVICE",2424 "UNPAIR_DEVICE", "RENAME_LOCAL_DEVICE", "LIST_PENDING_ADOPTION_HOLDERS", "REMOVE_PENDING_ADOPTION_HOLDER", "START_ADOPTION_SOURCE", "START_ADOPTION_RECEIVER",2525 "CANCEL_ADOPTION_SOURCE", "SOURCE_SAS_DECISION", "RECEIVER_SAS_DECISION", "ADOPTION_SOURCE_SAS", "ADOPTION_SOURCE_RESULT",26- "TRIGGER_SYNC", "GET_SYNC_STATE", "GET_SYNC_SETTINGS", "SET_SYNC_SETTINGS", "GET_PENDING_PASSKEY",26+ "TRIGGER_SYNC", "GET_SYNC_STATE", "GET_SYNC_GENERATION", "SECURE_RESET", "GET_SYNC_SETTINGS", "SET_SYNC_SETTINGS", "GET_PENDING_PASSKEY",2727 "APPROVE_PASSKEY", "DENY_PASSKEY", "GET_PASSKEY_PROVIDER_STATE", "SET_PASSKEY_PROVIDER_ENABLED",2828 "GET_DELETED_ENTRIES", "RESTORE_ENTRY", "PURGE_ENTRY", "PURGE_EXPIRED_TRASH", "GET_VAULT_HISTORY",2929 "RESTORE_VAULT_SNAPSHOT", "COUNT_DUPLICATES", "DEDUPLICATE_ENTRIES", "RECORD_MANUAL_SUMMON",src/background/message-policy.ts+2 −0
@@ -63,6 +63,8 @@6363 RECEIVER_SAS_DECISION: "extension-page-only",6464 TRIGGER_SYNC: "extension-page-only",6565 GET_SYNC_STATE: "extension-page-only",66+ GET_SYNC_GENERATION: "extension-page-only",67+ SECURE_RESET: "extension-page-only",6668 GET_SYNC_SETTINGS: "extension-page-only",6769 SET_SYNC_SETTINGS: "extension-page-only",6870 GET_PENDING_PASSKEY: "extension-page-only",src/background/messages.test.ts+4 −0
@@ -1717,6 +1717,10 @@17171717 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {17181718 const vault = await import("./vault");17191719 await vault.createVault("correct-pass-12345");1720+ // Every real install mints its identity with (or before) its vault, so a1721+ // fixture that seeds trust state without one is an install §11.5 refuses.1722+ const identity = await import("./device-identity");1723+ await identity.getOrCreateDeviceId();17201724 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =17211725 await import("./paired-devices");17221726 src/background/messages.ts+36 −0
@@ -111,6 +111,12 @@111111 getSyncState,112112 triggerSyncIfReachable,113113 } from "./sync-trigger";114+import {115+ browserGenerationIo,116+ confirmSecureReset,117+ enterResetRequired,118+ resolveGeneration,119+} from "./sync-generation";114120 import { isMessageSenderAllowed } from "./message-policy";115121 116122 const SUMMON_CAPABILITY_TTL_MS = 30_000;@@ -1755,6 +1761,36 @@17551761 case "GET_SYNC_STATE": {17561762 if (vault.isDecoyMode()) return { ok: true, data: { status: "idle" } };17571763 return { ok: true, data: getSyncState() };1764+ }1765+1766+ // §11. The generation state carries no secret — it is a version and a1767+ // state name — so the panel can read it while the vault is locked.1768+ case "GET_SYNC_GENERATION": {1769+ return { ok: true, data: await resolveGeneration(browserGenerationIo) };1770+ }1771+1772+ case "SECURE_RESET": {1773+ if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1774+ const payload = (msg.payload ?? {}) as { password?: unknown };1775+ if (typeof payload.password !== "string" || payload.password.length === 0) {1776+ return { ok: false, error: "Enter your master password" };1777+ }1778+ if (!(await vault.verifyMainPassword(payload.password))) {1779+ return { ok: false, error: "Incorrect master password" };1780+ }1781+ // Enter the blocking state first: a crash between these two calls then1782+ // leaves the device refusing sync rather than mid-reset over live1783+ // trust. Both are idempotent, so a retry is safe.1784+ await enterResetRequired(browserGenerationIo);1785+ const result = await confirmSecureReset(browserGenerationIo, {1786+ nowMs: Date.now(),1787+ });1788+ return result === "V2_ANCHOR"1789+ ? { ok: true, data: await resolveGeneration(browserGenerationIo) }1790+ : {1791+ ok: false,1792+ error: "The reset did not complete — nothing was lost, try again",1793+ };17581794 }17591795 17601796 case "GET_SYNC_SETTINGS": {src/background/pair-secrets.ts+10 −10
@@ -17,8 +17,8 @@1717 import { decrypt, encrypt, zeroKey } from "./aes";1818 import { listPairedDevices, writePairedDevices } from "./paired-devices";1919 20-const RECORD_KEY = "peach_pair_secrets_v1";21-const SESSION_KEY = "peach_pair_secrets_session";20+export const PAIR_SECRETS_KEY = "peach_pair_secrets_v1";21+export const SESSION_PAIR_SECRETS_KEY = "peach_pair_secrets_session";2222 const RECORD_VERSION = 1;2323 const AAD = "peach-pair-secrets-v1";2424 const WRAP_INFO = new TextEncoder().encode("peach-pair-secrets-v1");@@ -90,8 +90,8 @@9090 async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {9191 let stored: unknown;9292 try {93- const result = await chrome.storage.local.get(RECORD_KEY);94- stored = (result as Record<string, unknown>)[RECORD_KEY];93+ const result = await chrome.storage.local.get(PAIR_SECRETS_KEY);94+ stored = (result as Record<string, unknown>)[PAIR_SECRETS_KEY];9595 } catch {9696 return {};9797 }@@ -112,7 +112,7 @@112112 const wrap = wrappingKey(rootKey);113113 try {114114 const sealed = await encrypt(wrap, JSON.stringify(map), AAD);115- await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });115+ await chrome.storage.local.set({ [PAIR_SECRETS_KEY]: { v: RECORD_VERSION, ...sealed } });116116 } finally {117117 zeroKey(wrap);118118 }@@ -121,7 +121,7 @@121121 async function writeSessionCache(map: PairSecretMap): Promise<void> {122122 if (!sessionAllowed || !chrome.storage.session) return;123123 try {124- await chrome.storage.session.set({ [SESSION_KEY]: map });124+ await chrome.storage.session.set({ [SESSION_PAIR_SECRETS_KEY]: map });125125 } catch {126126 // Session storage is quota-bound; losing locked sync for the session is127127 // preferable to failing the caller (same rule as the locked-sync spool).@@ -131,8 +131,8 @@131131 async function readSessionCache(): Promise<PairSecretMap> {132132 if (!sessionAllowed || !chrome.storage.session) return {};133133 try {134- const result = await chrome.storage.session.get(SESSION_KEY);135- return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);134+ const result = await chrome.storage.session.get(SESSION_PAIR_SECRETS_KEY);135+ return sanitizeMap((result as Record<string, unknown>)[SESSION_PAIR_SECRETS_KEY]);136136 } catch {137137 return {};138138 }@@ -141,7 +141,7 @@141141 async function clearSessionCache(): Promise<void> {142142 if (!chrome.storage.session) return;143143 try {144- await chrome.storage.session.remove(SESSION_KEY);144+ await chrome.storage.session.remove(SESSION_PAIR_SECRETS_KEY);145145 } catch {146146 // Best effort: a stale operational copy is bounded by the browser session.147147 }@@ -256,7 +256,7 @@256256 export async function clearPairSecrets(): Promise<void> {257257 await serializeMutation(async () => {258258 try {259- await chrome.storage.local.remove(RECORD_KEY);259+ await chrome.storage.local.remove(PAIR_SECRETS_KEY);260260 } catch {261261 // Best effort alongside the registry clear it accompanies.262262 }src/background/paired-devices.ts+2 −2
@@ -20,8 +20,8 @@2020 import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";2121 import type { SyncOutcome } from "../shared/sync-outcome";2222 23-const REGISTRY_KEY = "peach_paired_devices_v3";24-const REVOCATIONS_KEY = "peach_device_revocations_v1";23+export const REGISTRY_KEY = "peach_paired_devices_v3";24+export const REVOCATIONS_KEY = "peach_device_revocations_v1";2525 /** Bound the list so a hostile peer cannot grow storage without limit. */2626 const MAX_REVOCATIONS = 500;2727 const LAST_SYNC_AT_KEY = "peach_last_sync_at";src/background/relay-adoption-bg.test.ts+77 −0
@@ -28,6 +28,8 @@2828 storageGet: vi.fn(),2929 storageRemove: vi.fn(),3030 sasDecisions: [] as Array<{ id: string; ok: boolean }>,31+ isPostReset: vi.fn(),32+ markRepair: vi.fn(),3133 }));3234 3335 vi.mock("../shared/pairing-dial", () => ({@@ -140,6 +142,12 @@140142 triggerSyncIfReachable: mocks.triggerSync,141143 }));142144 145+vi.mock("./sync-generation", () => ({146+ browserGenerationIo: {},147+ isPostReset: mocks.isPostReset,148+ onDirectV2PairingEstablished: mocks.markRepair,149+}));150+143151 vi.mock("./alarms", () => ({144152 deferAutoLockForOperation: mocks.deferAutoLock,145153 }));@@ -242,6 +250,10 @@242250 mocks.setPairSecret.mockReset();243251 mocks.triggerSync.mockReset();244252 mocks.triggerSync.mockResolvedValue(undefined);253+ mocks.isPostReset.mockReset();254+ mocks.isPostReset.mockResolvedValue(false);255+ mocks.markRepair.mockReset();256+ mocks.markRepair.mockResolvedValue(undefined);245257 mocks.releaseAutoLock.mockReset();246258 mocks.deferAutoLock.mockReset();247259 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);@@ -418,6 +430,71 @@418430 }419431 });420432 expect(sentTypes).toContain("ADOPT_COMPLETE");433+ });434+435+ it("refuses a v1 request without granting once this device has reset", async () => {436+ const socket = socketWithFrames([437+ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },438+ {439+ type: "ADOPT_REQUEST",440+ device_id: "legacy-label",441+ device_name: "Old phone",442+ device_type: "android",443+ },444+ ]);445+ mocks.openSocket.mockResolvedValue(socket);446+ mocks.isPostReset.mockResolvedValue(true);447+448+ await startRelayAdoptionSource();449+ await waitForPush("ADOPTION_SOURCE_RESULT");450+451+ expect(mocks.pushes).toContainEqual({452+ type: "ADOPTION_SOURCE_RESULT",453+ payload: { status: "error", error: "peer_protocol_outdated" },454+ });455+ expect(mocks.getRootKeyHex).not.toHaveBeenCalled();456+ expect(mocks.upsert).not.toHaveBeenCalled();457+ const sentTypes = socket.send.mock.calls.map(([bytes]) => {458+ try {459+ return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;460+ } catch {461+ return undefined;462+ }463+ });464+ expect(sentTypes).not.toContain("ADOPT_GRANT");465+ });466+467+ it("still runs the v1 transcript before a reset", async () => {468+ const socket = socketWithFrames([469+ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },470+ {471+ type: "ADOPT_REQUEST",472+ device_id: "legacy-label",473+ device_name: "Old phone",474+ device_type: "android",475+ },476+ { type: "ADOPT_CONFIRM", confirmed: true },477+ {478+ type: "ADOPT_IDENTITY",479+ device_id: "android-receiver",480+ device_name: "Old phone",481+ device_type: "android",482+ long_term_public_key_hex: "d".repeat(64),483+ },484+ { type: "ADOPT_APPLIED" },485+ ]);486+ mocks.openSocket.mockResolvedValue(socket);487+ mocks.isPostReset.mockResolvedValue(false);488+489+ await startRelayAdoptionSource();490+ await waitForPush("ADOPTION_SOURCE_RESULT");491+492+ // Non-ejectable, not withdrawn: the v1 path is unchanged until the reset.493+ expect(mocks.getRootKeyHex).toHaveBeenCalled();494+ expect(mocks.pushes).not.toContainEqual({495+ type: "ADOPTION_SOURCE_RESULT",496+ payload: { status: "error", error: "peer_protocol_outdated" },497+ });421498 });422499 423500 it("fails adoption without sending snapshot frames when duress is oversized", async () => {src/background/relay-adoption-bg.ts+21 −0
@@ -50,6 +50,11 @@5050 rotateDeviceId,5151 } from "./device-identity";5252 import {53+ browserGenerationIo,54+ isPostReset,55+ onDirectV2PairingEstablished,56+} from "./sync-generation";57+import {5358 hasAdoptionIdentityConflict,5459 markLastSyncedAt,5560 upsertPairedDeviceAndClearRevocation,@@ -335,6 +340,17 @@335340 },336341 snapshotCapable: () => true,337342 };343+ // §11.6: after a secure reset a v1 peer is repair-required, exactly as an344+ // outdated sync peer is. Refused before any grant. A new ADOPT_REJECT345+ // reason is not an option — §10.3 freezes the set to the two v1 peers346+ // already understand — so the refusal is local and the peer simply fails.347+ if (isAdoptRequest(request) && (await isPostReset(browserGenerationIo))) {348+ push("ADOPTION_SOURCE_RESULT", {349+ status: "error",350+ error: "peer_protocol_outdated",351+ });352+ return;353+ }338354 const device = isAdoptRequestV2(request)339355 ? await grantAdoptionV2(channel, request, sourceDeps)340356 : await grantAdoption(channel, request, sourceDeps);@@ -731,6 +747,11 @@731747 // Validate, persist, and clear the terminal tombstone under one registry732748 // mutation so a concurrent revoke cannot recreate or lose the tombstone.733749 await upsertPairedDeviceAndClearRevocation(pub);750+ // §11.3 REPAIRING → V2_ACTIVE. Anything adopted here is a direct pairing: it751+ // came from a grant, not from a manifest vouch, and on a device that has752+ // reset it can only be v2 — the post-reset source refuses v1 at the first753+ // frame. A device that never reset is untouched by this.754+ await onDirectV2PairingEstablished(browserGenerationIo, true);734755 if (!isValidPairSecret(secret)) return;735756 const masterKeyHex = getRootKeyHex();736757 if (!masterKeyHex) return;src/background/sync-generation.test.ts+385 −0
@@ -0,0 +1,385 @@1+// The §11 secure-reset contract: the durable generation record, the six states,2+// create-on-miss refusal, archival by generation, crash recovery at every step,3+// and the sync gate. Runs entirely against an in-memory io — no Chrome, no4+// vault, no crypto.5+6+import { describe, expect, it } from "vitest";7+import {8+ GENERATION_UNKNOWN,9+ LEGACY_ACTIVE,10+ REPAIRING,11+ RESET_CONFIRMED,12+ RESET_REQUIRED,13+ V2_ACTIVE,14+ V2_ANCHOR,15+ assertSyncAllowed,16+ confirmSecureReset,17+ decodeGeneration,18+ encodeGeneration,19+ enterResetRequired,20+ isPostReset,21+ mintDecision,22+ onDirectV2PairingEstablished,23+ resolveGeneration,24+ type ResetResult,25+ type SyncGeneration,26+ type SyncGenerationBlob,27+ type SyncGenerationIo,28+} from "./sync-generation";29+30+class CrashAt extends Error {}31+32+class FakeGenerationIo implements SyncGenerationIo {33+ blob: unknown = null;34+ pairedDevices = false;35+ revocations = false;36+ pendingHolders = false;37+ legacyRouteMaterial = false;38+39+ /** Storage that refuses the write, to model a generation that cannot land. */40+ failWrites = false;41+ failArchive = false;42+ failClear = false;43+ failIdentity = false;44+45+ /** Simulated process death: the checkpoint with this name throws. */46+ crashAt: string | null = null;47+48+ archiveRef: string | null = null;49+ archiveCount = 0;50+ cleared = false;51+ identityMints = 0;52+ deviceId = "anchor-device";53+54+ /** The order the reset actually touched storage. */55+ readonly calls: string[] = [];56+57+ get record(): SyncGeneration | null {58+ return decodeGeneration(this.blob)?.active ?? null;59+ }60+61+ async readBlob(): Promise<unknown> {62+ return this.blob;63+ }64+65+ async writeBlob(blob: SyncGenerationBlob): Promise<boolean> {66+ if (this.failWrites) return false;67+ this.blob = JSON.parse(encodeGeneration(blob)) as unknown;68+ return true;69+ }70+71+ async hasPairedDevices(): Promise<boolean> {72+ return this.pairedDevices;73+ }74+75+ async hasRevocations(): Promise<boolean> {76+ return this.revocations;77+ }78+79+ async hasPendingHolders(): Promise<boolean> {80+ return this.pendingHolders;81+ }82+83+ async hasLegacyRouteMaterial(): Promise<boolean> {84+ return this.legacyRouteMaterial;85+ }86+87+ async archiveLegacyState(generation: number): Promise<string | null> {88+ if (this.failArchive) return null;89+ this.archiveCount += 1;90+ this.archiveRef = `peach_sync_archive_g${generation}_v1`;91+ this.calls.push(`archive:${generation}`);92+ return this.archiveRef;93+ }94+95+ async clearLegacyTrustState(): Promise<boolean> {96+ if (this.failClear) return false;97+ this.cleared = true;98+ this.pairedDevices = false;99+ this.revocations = false;100+ this.pendingHolders = false;101+ this.legacyRouteMaterial = false;102+ this.calls.push("clear");103+ return true;104+ }105+106+ async ensureV2Identity(): Promise<boolean> {107+ if (this.failIdentity) return false;108+ this.identityMints += 1;109+ this.calls.push("identity");110+ return true;111+ }112+113+ async getDeviceId(): Promise<string> {114+ return this.deviceId;115+ }116+117+ reset(nowMs = 1_000): Promise<ResetResult> {118+ return confirmSecureReset(this, {119+ nowMs,120+ checkpoint: (name) => {121+ if (name === this.crashAt) throw new CrashAt(name);122+ },123+ });124+ }125+}126+127+const CHECKPOINTS = [128+ "before-confirmed",129+ "before-archive",130+ "after-archive",131+ "before-clear",132+ "after-clear",133+ "before-identity",134+ "after-identity",135+ "before-generation-write",136+ "after-generation-write",137+];138+139+describe("sync generation — derived state", () => {140+ it("derives V2_ACTIVE at generation 2 for a fresh install, and persists it", async () => {141+ const io = new FakeGenerationIo();142+ const record = await resolveGeneration(io, 1_000);143+ expect(record.state).toBe(V2_ACTIVE);144+ expect(record.generation).toBe(2);145+ expect(io.record).toEqual(record);146+ });147+148+ it("derives LEGACY_ACTIVE at generation 1 when any trust state is present", async () => {149+ const io = new FakeGenerationIo();150+ io.pairedDevices = true;151+ const record = await resolveGeneration(io, 1_000);152+ expect(record.state).toBe(LEGACY_ACTIVE);153+ expect(record.generation).toBe(1);154+ });155+156+ it("resolves an unreadable record to UNKNOWN rather than to a generation", async () => {157+ const io = new FakeGenerationIo();158+ io.blob = "{ not json";159+ expect((await resolveGeneration(io, 1_000)).state).toBe(GENERATION_UNKNOWN);160+ expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");161+ });162+163+ it("refuses a record of another schema instead of misreading it", async () => {164+ // The rollback case: a build that does not know the generation must not165+ // treat v2 state as legacy trust.166+ const io = new FakeGenerationIo();167+ io.blob = {168+ schemaVersion: 99,169+ active: { schemaVersion: 99, generation: 7, state: V2_ACTIVE, createdAt: 1 },170+ };171+ expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");172+ });173+174+ it("refuses an unrecognised state instead of guessing", async () => {175+ const io = new FakeGenerationIo();176+ io.blob = {177+ schemaVersion: 1,178+ active: { schemaVersion: 1, generation: 1, state: "WOBBLING", createdAt: 1 },179+ };180+ expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");181+ });182+183+ it("does not trust a derived record that could not be persisted", async () => {184+ const io = new FakeGenerationIo();185+ io.pairedDevices = true;186+ io.failWrites = true;187+ expect((await resolveGeneration(io, 1_000)).state).toBe(GENERATION_UNKNOWN);188+ expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");189+ });190+});191+192+describe("sync generation — create-on-miss refusal (§11.5)", () => {193+ it("refuses to mint over every class of trust state", async () => {194+ const classes: Array<[string, (io: FakeGenerationIo) => void]> = [195+ ["paired device", (io) => (io.pairedDevices = true)],196+ ["revocation", (io) => (io.revocations = true)],197+ ["pending holder", (io) => (io.pendingHolders = true)],198+ ["legacy route material", (io) => (io.legacyRouteMaterial = true)],199+ ];200+ for (const [label, configure] of classes) {201+ const io = new FakeGenerationIo();202+ configure(io);203+ expect(await mintDecision(io, 1_000), label).toBe("REFUSE_RESET_REQUIRED");204+ expect(io.record?.state, label).toBe(RESET_REQUIRED);205+ expect(io.identityMints, label).toBe(0);206+ expect(await assertSyncAllowed(io, 1_000), label).toBe("reset_required");207+ }208+ });209+210+ it("mints only on a genuinely fresh installation", async () => {211+ const io = new FakeGenerationIo();212+ expect(await mintDecision(io, 1_000)).toBe("MINT");213+ expect(io.record).toBeNull();214+ });215+});216+217+describe("sync generation — the reset", () => {218+ it("archives, clears, mints, then flips the pointer — in that order", async () => {219+ const io = new FakeGenerationIo();220+ io.pairedDevices = true;221+ io.revocations = true;222+223+ expect(await io.reset()).toBe("V2_ANCHOR");224+ expect(io.calls).toEqual(["archive:1", "clear", "identity"]);225+ expect(io.record).toMatchObject({226+ state: V2_ANCHOR,227+ generation: 2,228+ anchorDeviceId: "anchor-device",229+ legacyArchiveRef: "peach_sync_archive_g1_v1",230+ });231+ expect(io.cleared).toBe(true);232+ });233+234+ it("does not retire the v2 generation or mint twice when repeated", async () => {235+ const io = new FakeGenerationIo();236+ io.pairedDevices = true;237+ await io.reset();238+ const firstArchive = io.record?.legacyArchiveRef;239+240+ expect(await io.reset()).toBe("reset_not_completed");241+ expect(io.record?.legacyArchiveRef).toBe(firstArchive);242+ expect(io.identityMints).toBe(1);243+ });244+245+ it("stays in RESET_CONFIRMED and reports the failure when a step fails", async () => {246+ for (const configure of [247+ (io: FakeGenerationIo) => (io.failArchive = true),248+ (io: FakeGenerationIo) => (io.failClear = true),249+ (io: FakeGenerationIo) => (io.failIdentity = true),250+ (io: FakeGenerationIo) => (io.failWrites = true),251+ ]) {252+ const io = new FakeGenerationIo();253+ io.pairedDevices = true;254+ configure(io);255+ expect(await io.reset()).toBe("reset_not_completed");256+ expect(io.record?.state === RESET_CONFIRMED || io.record === null).toBe(true);257+ }258+ });259+});260+261+describe("sync generation — crash injection (§11.8)", () => {262+ it("leaves the archive durable and the retry reaches V2_ANCHOR from any crash point", async () => {263+ for (const name of CHECKPOINTS) {264+ const io = new FakeGenerationIo();265+ io.pairedDevices = true;266+ io.crashAt = name;267+268+ await expect(io.reset()).rejects.toThrow(CrashAt);269+270+ // Never a wrong outcome: nothing retired yet, mid-flight, or committed.271+ const state = io.record?.state;272+ expect([LEGACY_ACTIVE, RESET_CONFIRMED, V2_ANCHOR], name).toContain(state);273+ // Trust is never cleared without an archive first.274+ expect(io.cleared && io.archiveCount === 0, `${name} cleared without an archive`).toBe(false);275+276+ io.crashAt = null;277+ await io.reset();278+ expect(io.record?.state, `retry after ${name}`).toBe(V2_ANCHOR);279+ expect(io.record?.generation, `retry after ${name}`).toBe(2);280+ expect(io.archiveRef).not.toBeNull();281+ }282+ });283+284+ it("blocks sync before the pointer flip and allows it after", async () => {285+ const before = new FakeGenerationIo();286+ before.pairedDevices = true;287+ before.crashAt = "before-generation-write";288+ await expect(before.reset()).rejects.toThrow(CrashAt);289+ expect(before.record?.state).toBe(RESET_CONFIRMED);290+ expect(await assertSyncAllowed(before, 1_000)).toBe("reset_required");291+292+ const after = new FakeGenerationIo();293+ after.pairedDevices = true;294+ after.crashAt = "after-generation-write";295+ await expect(after.reset()).rejects.toThrow(CrashAt);296+ expect(after.record?.state).toBe(V2_ANCHOR);297+ expect(await assertSyncAllowed(after, 1_000)).toBe("allowed");298+ expect(after.archiveRef).not.toBeNull();299+ expect(after.identityMints).toBe(1);300+ });301+302+ it("loses nothing when the crash lands after the archive but before the clear", async () => {303+ const io = new FakeGenerationIo();304+ io.pairedDevices = true;305+ io.revocations = true;306+ io.crashAt = "before-clear";307+308+ await expect(io.reset()).rejects.toThrow(CrashAt);309+310+ expect(io.archiveCount).toBe(1);311+ expect(io.cleared).toBe(false);312+ expect(io.pairedDevices).toBe(true);313+ });314+});315+316+describe("sync generation — the sync gate (§7)", () => {317+ it("allows a legacy device to sync before it resets", async () => {318+ const io = new FakeGenerationIo();319+ io.pairedDevices = true;320+ expect(await assertSyncAllowed(io, 1_000)).toBe("allowed");321+ expect(await isPostReset(io, 1_000)).toBe(false);322+ });323+324+ it("allows a device that has already reset to sync and pair again", async () => {325+ const io = new FakeGenerationIo();326+ io.pairedDevices = true;327+ await io.reset();328+ expect(await assertSyncAllowed(io, 1_000)).toBe("allowed");329+ expect(await isPostReset(io, 1_000)).toBe(true);330+ });331+332+ it("blocks sync from RESET_REQUIRED and never walks a reset device backwards", async () => {333+ const legacy = new FakeGenerationIo();334+ legacy.pairedDevices = true;335+ await enterResetRequired(legacy, 1_000);336+ expect(await assertSyncAllowed(legacy, 1_000)).toBe("reset_required");337+338+ const reset = new FakeGenerationIo();339+ reset.pairedDevices = true;340+ await reset.reset();341+ await enterResetRequired(reset, 2_000);342+ expect(reset.record?.state).toBe(V2_ANCHOR);343+ });344+});345+346+describe("sync generation — repair progression (§11.3)", () => {347+ it("is REPAIRING until a direct peer exists, then V2_ACTIVE", async () => {348+ const io = new FakeGenerationIo();349+ io.pairedDevices = true;350+ await io.reset();351+352+ await onDirectV2PairingEstablished(io, false, 2_000);353+ expect(io.record?.state).toBe(REPAIRING);354+ expect(await isPostReset(io, 2_000)).toBe(true);355+356+ await onDirectV2PairingEstablished(io, true, 3_000);357+ expect(io.record?.state).toBe(V2_ACTIVE);358+ });359+360+ it("never disturbs a device that never reset", async () => {361+ const io = new FakeGenerationIo();362+ io.pairedDevices = true;363+ await onDirectV2PairingEstablished(io, true, 2_000);364+ expect(io.record?.state).toBe(LEGACY_ACTIVE);365+ });366+});367+368+describe("sync generation — §15.4: the reset retires a generation, it does not rotate the root", () => {369+ it("reaches storage only through archive, clear and identity, in that order", async () => {370+ const io = new FakeGenerationIo();371+ io.pairedDevices = true;372+ io.revocations = true;373+ io.legacyRouteMaterial = true;374+375+ expect(await io.reset()).toBe("V2_ANCHOR");376+377+ // The io is the whole surface the reset can reach. It carries no root key,378+ // no recovery material, and no vault-id parameter, so no rotation is379+ // possible by construction; what is worth pinning is the order that makes380+ // the archive safe.381+ expect(io.calls).toEqual(["archive:1", "clear", "identity"]);382+ expect(io.record?.legacyArchiveRef).toBe("peach_sync_archive_g1_v1");383+ expect(io.record?.generation).toBe(2);384+ });385+});src/background/sync-generation.ts+421 −0
@@ -0,0 +1,421 @@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.12+13+import { storageGet, storageSet } from "../shared/storage";14+import { PAIR_SECRETS_KEY, SESSION_PAIR_SECRETS_KEY } from "./pair-secrets";15+import {16+ PENDING_ADOPTION_HOLDER_KEY,17+ PENDING_ADOPTION_IDENTITY_KEY,18+} from "./pending-adoption-holder";19+import { REGISTRY_KEY, REVOCATIONS_KEY, listPairedDevices } from "./paired-devices";20+21+export const GENERATION_KEY = "peach_sync_generation_v1";22+23+const SCHEMA_VERSION = 1;24+const LEGACY_GENERATION = 1;25+const V2_GENERATION = 2;26+27+export const LEGACY_ACTIVE = "LEGACY_ACTIVE";28+export const RESET_REQUIRED = "RESET_REQUIRED";29+export const RESET_CONFIRMED = "RESET_CONFIRMED";30+export const V2_ANCHOR = "V2_ANCHOR";31+export const REPAIRING = "REPAIRING";32+export const V2_ACTIVE = "V2_ACTIVE";33+34+/** No record could be read or derived. Refuses sync rather than guessing. */35+export const GENERATION_UNKNOWN = "UNKNOWN";36+37+export type KnownGenerationState =38+ | typeof LEGACY_ACTIVE39+ | typeof RESET_REQUIRED40+ | typeof RESET_CONFIRMED41+ | typeof V2_ANCHOR42+ | typeof REPAIRING43+ | typeof V2_ACTIVE;44+45+export type GenerationState = KnownGenerationState | typeof GENERATION_UNKNOWN;46+47+export type SyncGeneration = {48+ schemaVersion: number;49+ generation: number;50+ state: GenerationState;51+ anchorDeviceId?: string;52+ legacyArchiveRef?: string;53+ createdAt: number;54+};55+56+/**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+ */61+export type SyncGenerationBlob = {62+ schemaVersion: number;63+ active: SyncGeneration;64+ previous?: SyncGeneration;65+};66+67+export type SyncDecision = "allowed" | "reset_required" | "sync_generation_unknown";68+69+export type ResetResult = "V2_ANCHOR" | "reset_not_completed";70+71+/**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+ */76+export 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+}88+89+const 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+};97+98+export function isGenerationState(value: unknown): value is KnownGenerationState {99+ return typeof value === "string" && STATE_NAMES[value] === true;100+}101+102+export function encodeGeneration(blob: SyncGenerationBlob): string {103+ return JSON.stringify(blob);104+}105+106+/**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+ */111+export 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+}126+127+export 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+}136+137+/**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+ */142+export 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+}167+168+/**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+ */173+export 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+}183+184+/**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+ */193+export 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+ }207+208+ const persist = (state: SyncGeneration): Promise<boolean> =>209+ io.writeBlob({ schemaVersion: SCHEMA_VERSION, active: state, previous: current });210+211+ checkpoint("before-confirmed");212+ if (!(await persist({ ...current, state: RESET_CONFIRMED }))) return "reset_not_completed";213+214+ checkpoint("before-archive");215+ const archiveRef = await io.archiveLegacyState(current.generation);216+ if (!archiveRef) return "reset_not_completed";217+ checkpoint("after-archive");218+219+ checkpoint("before-clear");220+ if (!(await io.clearLegacyTrustState())) return "reset_not_completed";221+ checkpoint("after-clear");222+223+ checkpoint("before-identity");224+ if (!(await io.ensureV2Identity())) return "reset_not_completed";225+ checkpoint("after-identity");226+227+ // 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+}245+246+/** An anchored device stops being "not re-paired" once a direct v2 peer exists. */247+export 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+}259+260+/** Consulted before a sync session or an adoption grant is opened. */261+export 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+}269+270+/** True once a reset has retired the legacy generation. */271+export 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+}278+279+/**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+ */284+export 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+}292+293+/**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+ */299+export type GenerationIdentity = {300+ /** True once a usable current identity exists; may mint at RESET_CONFIRMED. */301+ ensureIdentity(): Promise<boolean>;302+ deviceId(): Promise<string>;303+};304+305+let generationIdentity: GenerationIdentity | null = null;306+307+export function setGenerationIdentity(identity: GenerationIdentity): void {308+ generationIdentity = identity;309+}310+311+function emptyList(value: unknown): boolean {312+ return !Array.isArray(value) || value.length === 0;313+}314+315+function emptyRecord(value: unknown): boolean {316+ return !value || typeof value !== "object" || Object.keys(value as object).length === 0;317+}318+319+export const browserGenerationIo: SyncGenerationIo = {320+ async readBlob() {321+ return (await chrome.storage.local.get(GENERATION_KEY))[GENERATION_KEY];322+ },323+324+ async writeBlob(blob) {325+ const encoded = encodeGeneration(blob);326+ try {327+ await storageSet(GENERATION_KEY, encoded);328+ // Read back rather than trusting the set: a successful write is what329+ // licenses retiring the legacy generation.330+ return (await storageGet(GENERATION_KEY)) === encoded;331+ } catch {332+ return false;333+ }334+ },335+336+ async hasPairedDevices() {337+ return (await listPairedDevices()).length > 0;338+ },339+340+ async hasRevocations() {341+ return !emptyList((await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY]);342+ },343+344+ 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+ },351+352+ async hasLegacyRouteMaterial() {353+ return !emptyRecord((await chrome.storage.local.get(PAIR_SECRETS_KEY))[PAIR_SECRETS_KEY]);354+ },355+356+ /**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+ },389+390+ 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+ // Verified rather than assumed: the clear is what licenses the mint.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+ },412+413+ async ensureV2Identity() {414+ return generationIdentity ? generationIdentity.ensureIdentity() : false;415+ },416+417+ async getDeviceId() {418+ if (!generationIdentity) throw new Error("generation_identity_unconfigured");419+ return generationIdentity.deviceId();420+ },421+};src/background/sync-runner.test.ts+4 −0
@@ -198,6 +198,10 @@198198 async function bootVault(): Promise<typeof import("./vault")> {199199 const vault = await import("./vault");200200 await vault.createVault("correct horse battery staple");201+ // Every real install mints its identity with (or before) its vault, so a202+ // fixture that seeds trust state without one is an install §11.5 refuses.203+ const identity = await import("./device-identity");204+ await identity.getOrCreateDeviceId();201205 return vault;202206 }203207 src/background/sync-trigger.test.ts+60 −0
@@ -366,3 +366,63 @@366366 expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);367367 });368368 });369+370+describe("triggerSyncIfReachable — the §11 sync generation gate", () => {371+ beforeEach(() => {372+ makeStorageStub();373+ _resetSyncTriggerForTest();374+ });375+376+ function setGeneration(state: string) {377+ return (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })378+ .chrome.storage.local.set({379+ peach_sync_generation_v1: JSON.stringify({380+ schemaVersion: 1,381+ active: { schemaVersion: 1, generation: 1, state, createdAt: 1 },382+ }),383+ });384+ }385+386+ it("refuses to dial while this device owes a secure reset", async () => {387+ const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));388+ setSyncRunner(runner);389+ await upsertPairedDevice(ANDROID);390+ await setGeneration("RESET_REQUIRED");391+392+ await triggerSyncIfReachable({ reason: "manual" });393+394+ expect(runner).not.toHaveBeenCalled();395+ expect(getSyncState()).toMatchObject({ status: "error", outcome: "reset_required" });396+ });397+398+ it("refuses to dial when the generation cannot be read", async () => {399+ const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));400+ setSyncRunner(runner);401+ await upsertPairedDevice(ANDROID);402+ await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })403+ .chrome.storage.local.set({ peach_sync_generation_v1: "{ not json" });404+405+ await triggerSyncIfReachable({ reason: "manual" });406+407+ expect(runner).not.toHaveBeenCalled();408+ expect(getSyncState()).toMatchObject({ outcome: "sync_generation_unknown" });409+ });410+411+ it("still syncs an install that has never reset, and one that already has", async () => {412+ const seen: string[] = [];413+ setSyncRunner(async (device) => {414+ seen.push(device.deviceId);415+ return { ok: true, entriesReceived: 0, entriesSent: 0 };416+ });417+ await upsertPairedDevice(ANDROID);418+419+ // No record yet: the presence of a paired device derives LEGACY_ACTIVE.420+ await triggerSyncIfReachable({ reason: "manual" });421+ expect(seen).toEqual([ANDROID.deviceId]);422+423+ await setGeneration("V2_ACTIVE");424+ seen.length = 0;425+ await triggerSyncIfReachable({ reason: "manual" });426+ expect(seen).toEqual([ANDROID.deviceId]);427+ });428+});src/background/sync-trigger.ts+9 −0
@@ -11,6 +11,7 @@1111 // in `productionSyncRunner` from `./sync-runner` at module load.1212 1313 import * as vault from "./vault";14+import { assertSyncAllowed, browserGenerationIo } from "./sync-generation";1415 1516 import {1617 getLastSyncAt,@@ -161,6 +162,14 @@161162 state.syncInFlight = true;162163 163164 try {165+ // §11: a device that owes a secure reset, or whose generation cannot be166+ // read, opens no session at all. Checked before the device list so the167+ // refusal is reported as itself rather than as "no paired devices".168+ const decision = await assertSyncAllowed(browserGenerationIo);169+ if (decision !== "allowed") {170+ finish({ outcome: decision });171+ return;172+ }164173 const devices = await listPairedDevices();165174 const routeCapable = devices.filter(166175 (d) =>src/background/sync-vault-adapter.test.ts+4 −0
@@ -43,6 +43,10 @@4343 async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> {4444 const vault = await import("./vault");4545 await vault.createVault("correct horse battery staple");46+ // Every real install mints its identity with (or before) its vault, so a47+ // fixture that seeds trust state without one is an install §11.5 refuses.48+ const identity = await import("./device-identity");49+ await identity.getOrCreateDeviceId();4650 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");4751 const adapter = createSyncVaultAdapter();4852 return { vault, adapter };src/popup/views/Settings.tsx+146 −0
@@ -22,6 +22,7 @@2222 syncIndicatorLabel,2323 type SyncOutcomeSummary,2424 } from "../../shared/sync-outcome";25+import type { SyncGeneration } from "../../background/sync-generation";2526 import {2627 DEFAULT_BINDINGS,2728 findConflict,@@ -128,6 +129,55 @@128129 return null;129130 }130131 132+/**133+ * §11 migration copy. It must never imply that old peers keep working: the134+ * whole point of the reset is that they cannot.135+ */136+const SECURE_RESET_DISCLOSURE =137+ "Peach will archive this device's old Sync trust and start the new Sync generation. " +138+ "Every other device must be updated first, then re-paired with this one — they will " +139+ "not continue on their own. Your vault and your recovery words are not changed.";140+141+function secureResetHeadline(state: string | undefined): string {142+ switch (state) {143+ case "LEGACY_ACTIVE":144+ return "Legacy Sync generation";145+ case "RESET_REQUIRED":146+ return "Reset required";147+ case "RESET_CONFIRMED":148+ return "Reset not completed";149+ case "V2_ANCHOR":150+ case "REPAIRING":151+ return "Not re-paired";152+ case "V2_ACTIVE":153+ return "New Sync generation";154+ case "UNKNOWN":155+ return "Sync generation unreadable";156+ default:157+ return "Sync generation";158+ }159+}160+161+function secureResetDetail(state: string | undefined): string {162+ switch (state) {163+ case "LEGACY_ACTIVE":164+ return "This device still holds the old Sync trust. Resetting retires it for good: update Peach on every other device, then re-pair each one.";165+ case "RESET_REQUIRED":166+ return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";167+ case "RESET_CONFIRMED":168+ return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";169+ case "V2_ANCHOR":170+ case "REPAIRING":171+ return "This device anchors the new Sync generation and is not re-paired yet. Pair each device again; until then it keeps its own vault and cannot sync.";172+ case "V2_ACTIVE":173+ return "This device is on the new Sync generation. Each device is re-paired individually.";174+ case "UNKNOWN":175+ return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";176+ default:177+ return "Sync state is unavailable.";178+ }179+}180+131181 export function SettingsView(props: SettingsProps) {132182 const manifest = chrome.runtime.getManifest();133183 const version = manifest.version;@@ -262,6 +312,13 @@262312 // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —263313 // a transient toast is no use for diagnosing a phone that isn't syncing.264314 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});315+ // §11: the Sync generation this installation is on. Not secret — a version316+ // and a state name — so it renders the same in any session.317+ const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);318+ const [resetOpen, setResetOpen] = createSignal(false);319+ const [resetPassword, setResetPassword] = createSignal("");320+ const [resetError, setResetError] = createSignal<string | null>(null);321+ const [resetting, setResetting] = createSignal(false);265322 const syncLight = () =>266323 syncStatus() === "syncing"267324 ? "busy"@@ -283,6 +340,32 @@283340 }284341 }285342 343+ async function loadGeneration() {344+ const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {345+ ok: boolean;346+ data?: SyncGeneration | null;347+ };348+ if (r.ok) setGeneration(r.data ?? null);349+ }350+351+ async function runSecureReset() {352+ setResetting(true);353+ const r = (await sendMessage({354+ type: "SECURE_RESET",355+ payload: { password: resetPassword() },356+ })) as { ok: boolean; data?: SyncGeneration | null; error?: string };357+ setResetting(false);358+ setResetPassword("");359+ if (!r.ok) {360+ setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");361+ await loadGeneration();362+ return;363+ }364+ setResetError(null);365+ setResetOpen(false);366+ if (r.data) setGeneration(r.data);367+ }368+286369 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;287370 function syncRelativeTime(when: number | undefined): string {288371 if (!when) return "Not synced yet";@@ -592,6 +675,7 @@592675 void loadPendingVouched();593676 void loadPairedDevices();594677 void loadSyncState();678+ void loadGeneration();595679 const onBroadcast = (msg: unknown) => {596680 const m = msg as { type?: string; payload?: unknown } | null;597681 if (m?.type === "VOUCHED_DEVICE_PENDING") {@@ -1646,6 +1730,68 @@16461730 disabled={syncStatus() === "syncing"}16471731 onClick={() => void syncNow()}16481732 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1733+ </div>1734+ </Show>1735+1736+ <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1737+ <div class="peach-settings-row peach-settings-row--top">1738+ <div class="peach-settings-row-main">1739+ <span class="peach-settings-value" style="color:var(--peach-t1)">1740+ {secureResetHeadline(generation()?.state)}1741+ </span>1742+ <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1743+ {secureResetDetail(generation()?.state)}1744+ </span>1745+ </div>1746+ <Show1747+ when={1748+ generation()?.state === "LEGACY_ACTIVE" ||1749+ generation()?.state === "RESET_REQUIRED" ||1750+ generation()?.state === "RESET_CONFIRMED"1751+ }1752+ >1753+ <button1754+ class="peach-btn peach-btn--subtle peach-btn--sm"1755+ onClick={() => {1756+ setResetError(null);1757+ setResetPassword("");1758+ setResetOpen(true);1759+ }}1760+ >reset sync</button>1761+ </Show>1762+ </div>1763+ <Show when={resetError()}>1764+ <p1765+ class="peach-settings-value"1766+ role="alert"1767+ style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1768+ >{resetError()}</p>1769+ </Show>1770+ <Show when={resetOpen()}>1771+ <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1772+ <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1773+ {SECURE_RESET_DISCLOSURE}1774+ </p>1775+ <input1776+ class="peach-settings-input"1777+ type="password"1778+ placeholder="master password"1779+ value={resetPassword()}1780+ onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1781+ autocomplete="current-password"1782+ />1783+ <div style="display:flex;gap:4px">1784+ <button1785+ class="peach-btn peach-btn--subtle peach-btn--sm"1786+ disabled={resetting()}1787+ onClick={() => void runSecureReset()}1788+ >{resetting() ? "resetting…" : "reset"}</button>1789+ <button1790+ class="peach-btn peach-btn--subtle peach-btn--sm"1791+ disabled={resetting()}1792+ onClick={() => setResetOpen(false)}1793+ >cancel</button>1794+ </div>16491795 </div>16501796 </Show>16511797 src/shared/sync-outcome.test.ts+3 −0
@@ -94,6 +94,9 @@9494 handshake_failed: "warn",9595 vault_id_mismatch: "error",9696 peer_protocol_outdated: "error",97+ // §11: neither is a healthy sync, so neither may read green.98+ reset_required: "error",99+ sync_generation_unknown: "error",97100 sync_timeout: "warn",98101 connection_lost: "warn",99102 run_failed: "error",src/shared/sync-outcome.ts+9 −0
@@ -18,6 +18,8 @@1818 | "handshake_failed"1919 | "vault_id_mismatch"2020 | "peer_protocol_outdated"21+ | "reset_required"22+ | "sync_generation_unknown"2123 | "sync_timeout"2224 | "connection_lost"2325 | "run_failed";@@ -37,6 +39,10 @@3739 "private_relay_unset",3840 "vault_id_mismatch",3941 "peer_protocol_outdated",42+ // §11: a device that owes a secure reset, or whose generation cannot be43+ // read, has not synced and must be told so.44+ "reset_required",45+ "sync_generation_unknown",4046 "run_failed",4147 ]);4248 @@ -144,6 +150,9 @@144150 handshake_failed: "Reached the relay, but the device didn't answer",145151 vault_id_mismatch: "That device holds a different vault",146152 peer_protocol_outdated: "Update Peach on both devices, then re-pair them",153+ reset_required:154+ "Sync is reset on this device — update Peach everywhere, then re-pair each device",155+ sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",147156 sync_timeout: "The device stopped responding mid-sync",148157 connection_lost: "The connection dropped mid-sync — will retry",149158 run_failed: "Sync failed",src/shared/types.ts+2 −0
@@ -260,6 +260,8 @@260260 | "ADOPTION_SOURCE_RESULT"261261 | "TRIGGER_SYNC"262262 | "GET_SYNC_STATE"263+ | "GET_SYNC_GENERATION"264+ | "SECURE_RESET"263265 | "GET_SYNC_SETTINGS"264266 | "SET_SYNC_SETTINGS"265267 | "GET_PENDING_PASSKEY"