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
22 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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();

view file

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",

view file

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",

view file

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 

view file

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": {

view file

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     }

view file

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";

view file

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 () => {

view file

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;

view file

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+});

view file

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+};

view file

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 

view file

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+});

view file

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) =>

view file

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 };

view file

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 

view file

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",

view file

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",

view file

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"

view file