54ccdbf8d8f3

fix: Settle cross-client Sync and revocation parity (v5.2.26)

time
2026-09-09 13:35 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
settle cross-client sync
parent
1be1a75580c7
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/check-cross-client-sync.mjs+36 −0src/background/paired-devices.test.ts+19 −0src/background/paired-devices.ts+111 −39src/background/relay-adoption-bg.test.ts+12 −0src/background/relay-adoption-bg.ts+21 −1src/shared/vault-adopt.test.ts+147 −0src/shared/vault-adopt.ts+141 −44
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.25",4+  "version": "5.2.26",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.25",5+  "version": "5.2.26",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.25",3+  "version": "5.2.26",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/check-cross-client-sync.mjs+36 −0
@@ -16,6 +16,19 @@1616   resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"),1717   "utf8",1818 );19+const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");20+const androidManifest = readFileSync(21+  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"),22+  "utf8",23+);24+const browserPairedDevices = readFileSync(25+  resolve(browserRoot, "src/background/paired-devices.ts"),26+  "utf8",27+);28+const androidPairedDevices = readFileSync(29+  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"),30+  "utf8",31+);1932 2033 if (browserCoordinator.includes('helloIn.version === localVersion')) {2134   throw new Error("Browser still contains the forbidden equal-version fast path");@@ -26,6 +39,25 @@2639 if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) {2740   throw new Error("Android does not negotiate device manifests from SYNC_HELLO");2841 }42+if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) {43+  throw new Error("Android does not require an exact device-manifest version match");44+}45+if (!browserSyncTypes.includes("DEVICE_MANIFEST_VERSION = 0x04") ||46+    !androidManifest.includes("DEVICE_MANIFEST_VERSION = 0x04")) {47+  throw new Error("Browser and Android device-manifest versions differ");48+}49+if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") ||50+    !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) {51+  throw new Error("Browser and Android revocation ledger caps differ");52+}53+if (!browserPairedDevices.includes("enqueueDeviceMutation") ||54+    !androidPairedDevices.includes("RegistryMutationGate")) {55+  throw new Error("A paired-device registry lacks whole-list mutation serialization");56+}57+if (!browserPairedDevices.includes("function capRevocations") ||58+    !androidPairedDevices.includes("fun capRevocationRecords")) {59+  throw new Error("A client lacks largest-issuer-first revocation retention");60+}2961 3062 execFileSync(3163   "npm",@@ -38,6 +70,8 @@3870     "src/crypto/peachPayload.test.ts",3971     "src/shared/sync-entry-envelope.kat.test.ts",4072     "src/shared/vault-adopt.test.ts",73+    "src/background/paired-devices.test.ts",74+    "src/background/relay-adoption-bg.test.ts",4175   ],4276   { cwd: browserRoot, stdio: "inherit" },4377 );@@ -62,6 +96,8 @@6296     "com.peachpasswords.sync.SyncEntryEnvelopeKatTest",6397     "--tests",6498     "com.peachpasswords.sync.VaultAdoptTest",99+    "--tests",100+    "com.peachpasswords.sync.PairedDeviceRevocationTest",65101   ],66102   {67103     cwd: androidRoot,

view file

src/background/paired-devices.test.ts+19 −0
@@ -144,6 +144,25 @@144144     expect(untouched?.lastSyncedAt).toBeUndefined();145145   });146146 147+  it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {148+    const other: PairedDevice = {149+      ...SAMPLE,150+      deviceId: "BBB-222",151+      longTermPublicKeyHex: "1".repeat(64),152+    };153+    await upsertPairedDevice(SAMPLE);154+155+    await Promise.all([156+      upsertPairedDevice(other),157+      markLastSyncedAt(SAMPLE.deviceId, 1700001000000),158+    ]);159+160+    expect(await listPairedDevices()).toEqual([161+      { ...SAMPLE, lastSyncedAt: 1700001000000 },162+      other,163+    ]);164+  });165+147166   it("moves every existing pair to a new mesh relay", async () => {148167     const other: PairedDevice = {149168       ...SAMPLE,

view file

src/background/paired-devices.ts+111 −39
@@ -27,8 +27,15 @@2727 const MAX_REVOCATIONS = 500;2828 const LAST_SYNC_AT_KEY = "peach_last_sync_at";2929 const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";30+let deviceMutationTail: Promise<void> = Promise.resolve();3031 31-export async function listPairedDevices(): Promise<PairedDevice[]> {32+function enqueueDeviceMutation<T>(mutation: () => Promise<T>): Promise<T> {33+  const result = deviceMutationTail.then(mutation, mutation);34+  deviceMutationTail = result.then(() => undefined, () => undefined);35+  return result;36+}37+38+async function listPairedDevicesRaw(): Promise<PairedDevice[]> {3239   try {3340     const result = await chrome.storage.local.get(REGISTRY_KEY);3441     const devices = (result as Record<string, unknown>)[REGISTRY_KEY];@@ -39,6 +46,11 @@3946   }4047 }4148 49+export async function listPairedDevices(): Promise<PairedDevice[]> {50+  await deviceMutationTail;51+  return listPairedDevicesRaw();52+}53+4254 export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {4355   const devices = await listPairedDevices();4456   return devices.find((d) => d.deviceId === deviceId) ?? null;@@ -59,6 +71,10 @@5971  * missed writer fails closed to a secretless row instead of plaintext disk.6072  */6173 export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {74+  await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));75+}76+77+async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {6278   await chrome.storage.local.set({6379     [REGISTRY_KEY]: devices.map((device) => {6480       if (!isValidSecret(device.rendezvousSecretHex)) return device;@@ -87,15 +103,14 @@87103   // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile88104   // peer cannot grow the registry without limit; the local name already89105   // obeys the same bound at the Settings write path.90-  const stored = { ...device, deviceName: capDeviceName(device.deviceName) };91-  const devices = await listPairedDevices();92-  const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);93-  if (idx >= 0) {94-    devices[idx] = { ...devices[idx], ...stored };95-  } else {96-    devices.push(stored);97-  }98-  await writePairedDevices(devices);106+  await enqueueDeviceMutation(async () => {107+    const stored = { ...device, deviceName: capDeviceName(device.deviceName!) };108+    const devices = await listPairedDevicesRaw();109+    const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);110+    if (idx >= 0) devices[idx] = { ...devices[idx], ...stored };111+    else devices.push(stored);112+    await writePairedDevicesRaw(devices);113+  });99114   // The device-list-changed sync trigger fires from the messages.ts handlers100115   // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they101116   // own the post-mutation contract and have the full payload available. Firing@@ -106,11 +121,13 @@106121   const alias = name.trim();107122   if (!alias) throw new Error("device name must not be empty");108123   if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");109-  const devices = await listPairedDevices();110-  const index = devices.findIndex((device) => device.deviceId === deviceId);111-  if (index < 0) throw new Error("device_not_found");112-  devices[index] = { ...devices[index], localAlias: alias };113-  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });124+  await enqueueDeviceMutation(async () => {125+    const devices = await listPairedDevicesRaw();126+    const index = devices.findIndex((device) => device.deviceId === deviceId);127+    if (index < 0) throw new Error("device_not_found");128+    devices[index] = { ...devices[index], localAlias: alias };129+    await chrome.storage.local.set({ [REGISTRY_KEY]: devices });130+  });114131 }115132 116133 /**@@ -122,10 +139,12 @@122139 export async function setRelayUrlForAllPairedDevices(123140   relayUrl: string,124141 ): Promise<void> {125-  const devices = await listPairedDevices();126-  if (devices.length === 0) return;127-  await chrome.storage.local.set({128-    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),142+  await enqueueDeviceMutation(async () => {143+    const devices = await listPairedDevicesRaw();144+    if (devices.length === 0) return;145+    await chrome.storage.local.set({146+      [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),147+    });129148   });130149   // A relay change is mesh state — the next alarm tick or popup open will dial131150   // with the new relay. No fire-and-forget trigger here; the in-flight guard@@ -147,12 +166,12 @@147166  * the old device's key material is useless.148167  */149168 export async function removePairedDeviceById(deviceId: string): Promise<void> {150-  const devices = await listPairedDevices();151-  const filtered = devices.filter((d) => d.deviceId !== deviceId);152-  // Unpairing is local-only — there is no remote unpair RPC. The remote153-  // device retains this device in its own registry. Noise KK rejection on154-  // the next connection attempt is the security boundary.155-  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });169+  await enqueueDeviceMutation(async () => {170+    const devices = await listPairedDevicesRaw();171+    await chrome.storage.local.set({172+      [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),173+    });174+  });156175   // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE157176   // handler — see upsertPairedDevice for why this lives there, not here.158177 }@@ -161,7 +180,7 @@161180 // Device revocation (Part 5 §C) — a removal that propagates and stays removed.162181 // ---------------------------------------------------------------------------163182 164-export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {183+async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {165184   try {166185     const result = await chrome.storage.local.get(REVOCATIONS_KEY);167186     const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];@@ -172,6 +191,11 @@172191   }173192 }174193 194+export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {195+  await deviceMutationTail;196+  return listDeviceRevocationsRaw();197+}198+175199 /**176200  * True when either the id or the long-term key is under a terminal revocation.177201  *@@ -188,7 +212,8 @@188212   // (see revokeDeviceById) and must not match a real one.189213   const key = longTermPublicKeyHex?.toLowerCase();190214   const keyIsRevocable = key !== undefined && key !== "0".repeat(64);191-  return (await listDeviceRevocations()).some(215+  await deviceMutationTail;216+  return (await listDeviceRevocationsRaw()).some(192217     (r) =>193218       r.deviceId === deviceId ||194219       (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),@@ -207,14 +232,18 @@207232   revokedByDeviceId: string,208233   options: { now?: number } = {},209234 ): Promise<DeviceRevocation | null> {210-  const device = await findPairedDeviceById(deviceId);211-  const existing = await listDeviceRevocations();235+  return enqueueDeviceMutation(async () => {236+  const devices = await listPairedDevicesRaw();237+  const device = devices.find((candidate) => candidate.deviceId === deviceId);238+  const existing = await listDeviceRevocationsRaw();212239   if (existing.some((r) => r.deviceId === deviceId)) {213240     // A direct re-pair in older builds could restore the paired row without214241     // clearing its terminal revocation. "Remove" then returned early here and215242     // left the row visible forever. Idempotency applies to the revocation216243     // record, not to removing any stale live row that overlaps it.217-    if (device) await removePairedDeviceById(deviceId);244+    if (device) await chrome.storage.local.set({245+      [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),246+    });218247     return null;219248   }220249 @@ -227,12 +256,49 @@227256     revokedAt: options.now ?? Date.now(),228257     revokedByDeviceId,229258   };230-  await storeRevocations([...existing, record]);231-  await removePairedDeviceById(deviceId);259+  await chrome.storage.local.set({260+    [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),261+    [REVOCATIONS_KEY]: capRevocations([...existing, record]),262+  });232263   return record;264+  });233265 }234266 235267 /**268+ * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed269+ * re-pair — a physically present user on both devices. Never call this from270+ * a path a remote peer can reach.271+ */272+export async function clearDeviceRevocation(deviceId: string): Promise<void> {273+  await enqueueDeviceMutation(async () => {274+  const existing = await listDeviceRevocationsRaw();275+  const filtered = existing.filter((r) => r.deviceId !== deviceId);276+  if (filtered.length !== existing.length) {277+    await storeRevocationsRaw(filtered);278+  }279+  });280+}281+282+/**283+ * True when the incoming identity matches an existing paired device's deviceId284+ * OR a terminal revocation record (by deviceId or long-term public key).285+ * A fresh pairing ceremony must not reuse terminally revoked identity material.286+ */287+export async function hasAdoptionIdentityConflict(288+  deviceId: string,289+  longTermPublicKeyHex: string,290+): Promise<boolean> {291+  await deviceMutationTail;292+  const devices = await listPairedDevicesRaw();293+  if (devices.some((d) => d.deviceId === deviceId)) return true;294+  const key = longTermPublicKeyHex.toLowerCase();295+  return (await listDeviceRevocationsRaw()).some((record) =>296+    record.deviceId === deviceId ||297+    (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)298+  );299+}300+301+/**236302  * Merge revocations learned from a peer's manifest. Returns the deviceIds that237303  * were newly revoked, so the caller can evict them.238304  *@@ -243,7 +309,8 @@243309 export async function mergeDeviceRevocations(244310   incoming: DeviceRevocation[],245311 ): Promise<string[]> {246-  const existing = await listDeviceRevocations();312+  return enqueueDeviceMutation(async () => {313+  const existing = await listDeviceRevocationsRaw();247314   const known = new Set(existing.map((r) => r.deviceId));248315   const fresh = incoming249316     .filter((r) => !known.has(r.deviceId))@@ -252,8 +319,9 @@252319     // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).253320     .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));254321   if (fresh.length === 0) return [];255-  await storeRevocations([...existing, ...fresh]);322+  await storeRevocationsRaw([...existing, ...fresh]);256323   return fresh.map((r) => r.deviceId);324+  });257325 }258326 259327 /**@@ -268,8 +336,9 @@268336   revocationsCleared: number;269337   pairedRowsRemoved: number;270338 }> {271-  const devices = await listPairedDevices();272-  const revocations = await listDeviceRevocations();339+  return enqueueDeviceMutation(async () => {340+  const devices = await listPairedDevicesRaw();341+  const revocations = await listDeviceRevocationsRaw();273342   const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));274343   const revocationIdsToClear = new Set<string>();275344   const pairedIdsToRemove = new Set<string>();@@ -298,9 +367,10 @@298367     revocationsCleared: revocationIdsToClear.size,299368     pairedRowsRemoved: pairedIdsToRemove.size,300369   };370+  });301371 }302372 303-async function storeRevocations(records: DeviceRevocation[]): Promise<void> {373+async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {304374   await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });305375 }306376 @@ -358,15 +428,17 @@358428 }359429 360430 export async function clearPairedDevices(): Promise<void> {361-  await chrome.storage.local.remove(REGISTRY_KEY);431+  await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));362432 }363433 364434 export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {365-  const devices = await listPairedDevices();435+  await enqueueDeviceMutation(async () => {436+  const devices = await listPairedDevicesRaw();366437   const idx = devices.findIndex((d) => d.deviceId === deviceId);367438   if (idx < 0) return;368439   devices[idx] = { ...devices[idx], lastSyncedAt: when };369440   await chrome.storage.local.set({ [REGISTRY_KEY]: devices });441+  });370442 }371443 372444 export async function getLastSyncAt(): Promise<number | null> {

view file

src/background/relay-adoption-bg.test.ts+12 −0
@@ -7,6 +7,9 @@77   getDeviceName: vi.fn(),88   getRootKeyHex: vi.fn(),99   upsert: vi.fn(),10+  clearRevocation: vi.fn(),11+  hasIdentityConflict: vi.fn(),12+  rotateDeviceId: vi.fn(),1013   setPairSecret: vi.fn(),1114   pushes: [] as Array<{ type: string; payload: unknown }>,1215   triggerSync: vi.fn(),@@ -60,6 +63,7 @@6063   }),6164   getDeviceName: mocks.getDeviceName,6265   buildLocalIdentity: mocks.localIdentity,66+  rotateDeviceId: mocks.rotateDeviceId,6367 }));6468 6569 vi.mock("./sync-settings", async (importOriginal) => ({@@ -103,6 +107,8 @@103107 104108 vi.mock("./paired-devices", () => ({105109   upsertPairedDevice: mocks.upsert,110+  clearDeviceRevocation: mocks.clearRevocation,111+  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,106112 }));107113 108114 vi.mock("./pair-secrets", () => ({@@ -173,6 +179,12 @@173179   mocks.getLocalIdentity.mockReset();174180   mocks.getRootKeyHex.mockReset();175181   mocks.upsert.mockReset();182+  mocks.clearRevocation.mockReset();183+  mocks.clearRevocation.mockResolvedValue(undefined);184+  mocks.hasIdentityConflict.mockReset();185+  mocks.hasIdentityConflict.mockResolvedValue(false);186+  mocks.rotateDeviceId.mockReset();187+  mocks.rotateDeviceId.mockResolvedValue(undefined);176188   mocks.setPairSecret.mockReset();177189   mocks.triggerSync.mockReset();178190   mocks.triggerSync.mockResolvedValue(undefined);

view file

src/background/relay-adoption-bg.ts+21 −1
@@ -47,7 +47,7 @@4747 import { computeVaultId } from "../shared/sync-identity";4848 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4949 import { storageRemove, storageSet } from "../shared/storage";50-import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";50+import { markLastSyncedAt, upsertPairedDevice, clearDeviceRevocation, hasAdoptionIdentityConflict } from "./paired-devices";5151 import { isValidPairSecret, setPairSecret } from "./pair-secrets";5252 import { createSyncVaultAdapter } from "./sync-vault-adapter";5353 import { triggerSyncIfReachable } from "./sync-trigger";@@ -291,6 +291,19 @@291291         crypto.randomUUID(),292292       ),293293       upsertPairedDevice: storeAdoptedDevice,294+      hasDeviceIdentityCollision: async (device) =>295+        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),296+      rotateSourceIdentity: async () => {297+        await rotateDeviceId();298+        const masterKeyHex = getRootKeyHex();299+        if (!masterKeyHex) throw new Error("identity_unavailable");300+        const masterKey = hexToBytes(masterKeyHex);301+        try {302+          return await buildLocalIdentity(masterKey);303+        } finally {304+          masterKey.fill(0);305+        }306+      },294307     });295308     if (!device) {296309       push("ADOPTION_SOURCE_RESULT", {@@ -463,6 +476,9 @@463476         // Direct call for the same reason as applyLicense above.464477         await storeAdoptedDevice(paired);465478       },479+      async hasSourceIdentityCollision(source) {480+        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);481+      },466482       pairState: {467483         rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),468484         relayUrl,@@ -692,6 +708,10 @@692708  * cannot recover this secret — re-pair is the fail-closed path.693709  */694710 async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {711+  // SAS-confirmed direct adoption — the one consent signal strong enough to712+  // lift a terminal revocation. Clear before upsert so storage never exposes713+  // the device as both paired and revoked, even transiently.714+  await clearDeviceRevocation(paired.deviceId);695715   const secret = paired.rendezvousSecretHex;696716   const pub = { ...paired };697717   delete pub.rendezvousSecretHex;

view file

src/shared/vault-adopt.test.ts+147 −0
@@ -159,6 +159,153 @@159159     expect(receiverDeps.onError).not.toHaveBeenCalled();160160   });161161 162+  it("T-A10d: receiver rejects a revoked source identity and source rotates", async () => {163+    const [receiverChannel, sourceChannel] = memoryPair();164+    const rootKeyHex = "14".repeat(32);165+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));166+    const revokedSourceIdentity = identity("chrome");167+    const freshSourceIdentity = identity("chrome-fresh");168+    const receiverUpsert = vi.fn();169+    const receiverDeps: AdoptionReceiverDeps = {170+      deviceName: "Android",171+      generateDeviceId: () => "receiver-ephemeral",172+      confirmSas: vi.fn(async () => true),173+      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),174+      deriveLocalIdentity: vi.fn(async () => identity("receiver")),175+      upsertPairedDevice: receiverUpsert,176+      hasSourceIdentityCollision: vi.fn(async (source) =>177+        source.device_id === revokedSourceIdentity.deviceId,178+      ),179+      onError: vi.fn(),180+    };181+182+    const receiverPromise = requestAdoption(receiverChannel, receiverDeps);183+    await sourceChannel.receive(); // ADOPT_REQUEST184+    await sourceChannel.receive(); // ADOPT_CONFIRM185+186+    // First grant with revoked identity187+    await sendFrame(sourceChannel, {188+      type: "ADOPT_GRANT",189+      root_key_hex: rootKeyHex,190+      vault_id: vaultId,191+      license: "PPK-test",192+      source: {193+        device_id: revokedSourceIdentity.deviceId,194+        device_name: revokedSourceIdentity.deviceName,195+        device_type: revokedSourceIdentity.deviceType,196+        long_term_public_key_hex: revokedSourceIdentity.longTermPublicKeyHex,197+      },198+    });199+200+    // Receiver sends ADOPT_REJECT(COLLISION) instead of ADOPT_IDENTITY201+    const firstResponse = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));202+    expect(firstResponse.type).toBe("ADOPT_REJECT");203+    expect(firstResponse.reason).toBe("ADOPT_DEVICE_ID_COLLISION");204+205+    // Source rotates and resends grant with fresh identity206+    await sendFrame(sourceChannel, {207+      type: "ADOPT_GRANT",208+      root_key_hex: rootKeyHex,209+      vault_id: vaultId,210+      license: "PPK-test",211+      source: {212+        device_id: freshSourceIdentity.deviceId,213+        device_name: freshSourceIdentity.deviceName,214+        device_type: freshSourceIdentity.deviceType,215+        long_term_public_key_hex: freshSourceIdentity.longTermPublicKeyHex,216+      },217+    });218+219+    // Receiver accepts the fresh identity and sends ADOPT_IDENTITY220+    const identityFrame = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));221+    expect(identityFrame.type).toBe("ADOPT_IDENTITY");222+    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });223+    await sourceChannel.receive(); // ADOPT_APPLIED224+225+    const result = await receiverPromise;226+    expect(result).not.toBeNull();227+    expect(result!.device.deviceId).toBe(freshSourceIdentity.deviceId);228+    expect(receiverDeps.hasSourceIdentityCollision).toHaveBeenCalledTimes(2);229+    expect(receiverDeps.onError).not.toHaveBeenCalled();230+  });231+232+  it("T-A10e: source detects a revoked receiver identity and forces rotation", async () => {233+    const [receiverChannel, sourceChannel] = memoryPair();234+    const rootKeyHex = "15".repeat(32);235+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));236+    const collidingIdentity = identity("chrome");237+    const freshIdentity = identity("firefox");238+    const sourceUpsert = vi.fn();239+    const sourceDeps: AdoptionSourceDeps = {240+      sourceIdentity: identity("android"),241+      rootKeyHex: vi.fn(async () => rootKeyHex),242+      vaultId: vi.fn(async () => vaultId),243+      license: vi.fn(async () => "PPK-test"),244+      onConsent: vi.fn(async () => true),245+      upsertPairedDevice: sourceUpsert,246+      hasDeviceIdentityCollision: vi.fn(async (device) =>247+        device.deviceId === collidingIdentity.deviceId,248+      ),249+    };250+251+    const sourcePromise = (async () => {252+      const request = JSON.parse(253+        new TextDecoder().decode(await sourceChannel.receive()),254+      ) as AdoptRequest;255+      return grantAdoption(sourceChannel, request, sourceDeps);256+    })();257+258+    // Receiver side: send request and confirm259+    await sendFrame(receiverChannel, {260+      type: "ADOPT_REQUEST",261+      device_id: "receiver-ephemeral",262+      device_name: "Chrome",263+      device_type: "extension",264+      license: "PPK-test",265+      snapshot_capable: true,266+    });267+    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });268+269+    // Receive grant270+    const grant = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));271+    expect(grant.type).toBe("ADOPT_GRANT");272+273+    // Send colliding identity274+    await sendFrame(receiverChannel, {275+      type: "ADOPT_IDENTITY",276+      device_id: collidingIdentity.deviceId,277+      device_name: collidingIdentity.deviceName,278+      device_type: collidingIdentity.deviceType,279+      long_term_public_key_hex: collidingIdentity.longTermPublicKeyHex,280+      signing_public_key_hex: collidingIdentity.signingPublicKeyHex,281+    });282+283+    // Source sends ADOPT_REJECT(COLLISION)284+    const reject = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));285+    expect(reject.type).toBe("ADOPT_REJECT");286+    expect(reject.reason).toBe("ADOPT_DEVICE_ID_COLLISION");287+288+    // Receiver sends fresh identity289+    await sendFrame(receiverChannel, {290+      type: "ADOPT_IDENTITY",291+      device_id: freshIdentity.deviceId,292+      device_name: freshIdentity.deviceName,293+      device_type: freshIdentity.deviceType,294+      long_term_public_key_hex: freshIdentity.longTermPublicKeyHex,295+      signing_public_key_hex: freshIdentity.signingPublicKeyHex,296+    });297+298+    // Source sends ADOPT_COMPLETE299+    const complete = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));300+    expect(complete.type).toBe("ADOPT_COMPLETE");301+    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });302+303+    const result = await sourcePromise;304+    expect(result).not.toBeNull();305+    expect(result!.deviceId).toBe(freshIdentity.deviceId);306+    expect(sourceDeps.hasDeviceIdentityCollision).toHaveBeenCalledTimes(2);307+  });308+162309   it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {163310     const [receiverChannel, sourceChannel] = memoryPair();164311     const rootKeyHex = "12".repeat(32);

view file

src/shared/vault-adopt.ts+141 −44
@@ -253,6 +253,12 @@253253    * validated but before any vault state changes.254254    */255255   validateSource?(source: AdoptGrant["source"]): Promise<void>;256+  /**257+   * True when the source's identity (from ADOPT_GRANT) is in the local258+   * terminal revocation ledger. When true, the receiver sends259+   * ADOPT_DEVICE_ID_COLLISION so the source rotates and resends the grant.260+   */261+  hasSourceIdentityCollision?(source: AdoptGrant["source"]): Promise<boolean>;256262   createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257263   deriveLocalIdentity(): Promise<LocalIdentity>;258264   rotateLocalIdentity?(): Promise<LocalIdentity>;@@ -274,6 +280,18 @@274280   applyLicense?(license: string): Promise<void>;275281   onConsent(request: AdoptRequest): Promise<boolean>;276282   upsertPairedDevice(device: PairedDevice): Promise<void>;283+  /**284+   * True when the receiver's identity conflicts with an existing paired285+   * device or a terminal revocation record. When true, the source sends286+   * ADOPT_DEVICE_ID_COLLISION so the receiver rotates its identity.287+   */288+  hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;289+  /**290+   * Rotate the source's identity after the receiver reports a collision on291+   * the source's own identity. Returns the fresh identity to use in the292+   * resent ADOPT_GRANT.293+   */294+  rotateSourceIdentity?(): Promise<LocalIdentity>;277295   /** Bound to the same completed Noise session, never taken from frame claims. */278296   pairState?: AdoptionPairState;279297 }@@ -643,14 +661,46 @@643661   }644662 645663   await deps.validateSource?.(frame.source);646-  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);647-  if (vaultId !== frame.vault_id) {664+665+  // Check the source's identity against the local revocation ledger. A666+  // previously-revoked device must present a fresh identity so that stale667+  // signed revocations held by other mesh peers cannot remove it after668+  // re-pair. If the source's identity is revoked, send ADOPT_DEVICE_ID_COLLISION669+  // and wait for a fresh grant (one retry).670+  let grant = frame;671+  if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {672+    await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });673+    const retry = await receiveFrame(channel);674+    if (!isAdoptGrant(retry)) {675+      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");676+      return null;677+    }678+    grant = retry;679+    if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {680+      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });681+      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");682+      return null;683+    }684+    // Re-validate the fresh grant's vault id.685+    const freshRootBytes = hexToBytes(grant.root_key_hex);686+    try {687+      if (await computeVaultId(freshRootBytes) !== grant.vault_id) {688+        deps.onError("ADOPT_VAULT_ID_MISMATCH");689+        return null;690+      }691+    } finally {692+      freshRootBytes.fill(0);693+    }694+  }695+696+  const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);697+  if (vaultId !== grant.vault_id) {648698     deps.onError("ADOPT_VAULT_ID_MISMATCH");649699     return null;650700   }651701 652-  if (!receiverLicense && frame.license) {653-    await deps.applyLicense?.(frame.license);702+  if (!receiverLicense && grant.license) {703+    await deps.applyLicense?.(grant.license);654704   }655705 656706   let identity = await deps.deriveLocalIdentity();@@ -686,15 +736,15 @@686736   }687737 688738   const sourceDevice: PairedDevice = {689-    deviceId: frame.source.device_id,690-    deviceName: capDeviceName(frame.source.device_name),691-    deviceType: frame.source.device_type,692-    longTermPublicKeyHex: frame.source.long_term_public_key_hex,693-    signingPublicKeyHex: frame.source.signing_public_key_hex,739+    deviceId: grant.source.device_id,740+    deviceName: capDeviceName(grant.source.device_name),741+    deviceType: grant.source.device_type,742+    longTermPublicKeyHex: grant.source.long_term_public_key_hex,743+    signingPublicKeyHex: grant.source.signing_public_key_hex,694744     trustSource: "direct",695-    wsPort: frame.source.ws_port,745+    wsPort: grant.source.ws_port,696746     rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,697-    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,747+    relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,698748     pairedAt: Date.now(),699749   };700750   await deps.upsertPairedDevice(sourceDevice);@@ -734,41 +784,88 @@734784   const rootKeyHex = await deps.rootKeyHex();735785   if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");736786 737-  await sendFrame(channel, {738-    type: "ADOPT_GRANT",739-    root_key_hex: rootKeyHex,740-    vault_id: await deps.vaultId(),741-    license: sourceLicense,742-    source: {743-      device_id: deps.sourceIdentity.deviceId,744-      device_name: deps.sourceIdentity.deviceName,745-      device_type: deps.sourceIdentity.deviceType,746-      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,747-      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,748-      ws_port: deps.sourceIdentity.wsPort,749-      relay_url: deps.pairState?.relayUrl,750-    },751-    snapshot_capable: request.snapshot_capable === true,752-  });787+  let sourceIdentity = deps.sourceIdentity;788+  let grantAttempts = 0;789+  while (true) {790+    await sendFrame(channel, {791+      type: "ADOPT_GRANT",792+      root_key_hex: rootKeyHex,793+      vault_id: await deps.vaultId(),794+      license: sourceLicense,795+      source: {796+        device_id: sourceIdentity.deviceId,797+        device_name: sourceIdentity.deviceName,798+        device_type: sourceIdentity.deviceType,799+        long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,800+        signing_public_key_hex: sourceIdentity.signingPublicKeyHex,801+        ws_port: sourceIdentity.wsPort,802+        relay_url: deps.pairState?.relayUrl,803+      },804+      snapshot_capable: request.snapshot_capable === true,805+    });753806 754-  const identity = await receiveFrame(channel);755-  if (!isAdoptIdentity(identity)) return null;807+    const identity = await receiveFrame(channel);756808 757-  const device: PairedDevice = {758-    deviceId: identity.device_id,759-    deviceName: capDeviceName(identity.device_name),760-    deviceType: identity.device_type,761-    longTermPublicKeyHex: identity.long_term_public_key_hex,762-    signingPublicKeyHex: identity.signing_public_key_hex,763-    trustSource: "direct",764-    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,765-    relayUrl: deps.pairState?.relayUrl,766-    pairedAt: Date.now(),767-  };768-  await deps.upsertPairedDevice(device);769-  await sendFrame(channel, { type: "ADOPT_COMPLETE" });770-  if (!isAdoptApplied(await receiveFrame(channel))) return null;771-  return device;809+    // Receiver may reject the source's identity if it is in the receiver's810+    // revocation ledger. Rotate and resend (one retry).811+    if (812+      isAdoptReject(identity) &&813+      identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&814+      deps.rotateSourceIdentity &&815+      grantAttempts === 0816+    ) {817+      sourceIdentity = await deps.rotateSourceIdentity();818+      grantAttempts++;819+      continue;820+    }821+822+    if (!isAdoptIdentity(identity)) return null;823+824+    const device: PairedDevice = {825+      deviceId: identity.device_id,826+      deviceName: capDeviceName(identity.device_name),827+      deviceType: identity.device_type,828+      longTermPublicKeyHex: identity.long_term_public_key_hex,829+      signingPublicKeyHex: identity.signing_public_key_hex,830+      trustSource: "direct",831+      rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,832+      relayUrl: deps.pairState?.relayUrl,833+      pairedAt: Date.now(),834+    };835+836+    // Check the receiver's identity against local revocations (matching837+    // Android's grantAdoption collision detection). If revoked, send838+    // ADOPT_DEVICE_ID_COLLISION so the receiver rotates (one retry).839+    if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {840+      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });841+      const freshIdentity = await receiveFrame(channel);842+      if (!isAdoptIdentity(freshIdentity)) return null;843+      const freshDevice: PairedDevice = {844+        deviceId: freshIdentity.device_id,845+        deviceName: capDeviceName(freshIdentity.device_name),846+        deviceType: freshIdentity.device_type,847+        longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,848+        signingPublicKeyHex: freshIdentity.signing_public_key_hex,849+        trustSource: "direct",850+        rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,851+        relayUrl: deps.pairState?.relayUrl,852+        pairedAt: Date.now(),853+      };854+      if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {855+        await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });856+        return null;857+      }858+      await deps.upsertPairedDevice(freshDevice);859+      await sendFrame(channel, { type: "ADOPT_COMPLETE" });860+      if (!isAdoptApplied(await receiveFrame(channel))) return null;861+      return freshDevice;862+    }863+864+    await deps.upsertPairedDevice(device);865+    await sendFrame(channel, { type: "ADOPT_COMPLETE" });866+    if (!isAdoptApplied(await receiveFrame(channel))) return null;867+    return device;868+  }772869 }773870 774871 export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {

view file