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
↓ peach-browser-54ccdbf8.tar.gzatlas at this save
files changed10 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 −44manifest.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": {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",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",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,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,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> {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);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;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);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> {