68e79607733c

fix: rotate copied browser identity during adoption (v5.2.25)

time
2026-09-09 01:36 UTC · 1 month ago
machine
koh2
parent
d81f77dc4b6c
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/check-cross-client-sync.mjs+3 −0src/background/device-identity.test.ts+12 −0src/background/device-identity.ts+7 −0src/background/relay-adoption-bg.ts+17 −1src/shared/vault-adopt.test.ts+50 −0src/shared/vault-adopt.ts+22 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.24",4+  "version": "5.2.25",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.24",5+  "version": "5.2.25",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.24",3+  "version": "5.2.25",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/check-cross-client-sync.mjs+3 −0
@@ -37,6 +37,7 @@3737     "src/shared/device-manifest.test.ts",3838     "src/crypto/peachPayload.test.ts",3939     "src/shared/sync-entry-envelope.kat.test.ts",40+    "src/shared/vault-adopt.test.ts",4041   ],4142   { cwd: browserRoot, stdio: "inherit" },4243 );@@ -59,6 +60,8 @@5960     "com.peachpasswords.vault.PeachScriptTest",6061     "--tests",6162     "com.peachpasswords.sync.SyncEntryEnvelopeKatTest",63+    "--tests",64+    "com.peachpasswords.sync.VaultAdoptTest",6265   ],6366   {6467     cwd: androidRoot,

view file

src/background/device-identity.test.ts+12 −0
@@ -70,4 +70,16 @@7070 7171     expect(second).toBe(first);7272   });73+74+  it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => {75+    local[DEVICE_ID_KEY] = "copied-browser-id";76+    const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity");77+78+    const fresh = await rotateDeviceId();79+80+    expect(fresh).not.toBe("copied-browser-id");81+    expect(local[DEVICE_ID_KEY]).toBe(fresh);82+    expect(await getOrCreateDeviceId()).toBe(fresh);83+    expect(syncSet).not.toHaveBeenCalled();84+  });7385 });

view file

src/background/device-identity.ts+7 −0
@@ -48,6 +48,13 @@4848   return fresh;4949 }5050 51+/** Replace a copied profile identity after an authenticated peer detects a collision. */52+export async function rotateDeviceId(): Promise<string> {53+  const fresh = generateUuidV4();54+  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });55+  return fresh;56+}57+5158 export async function getDeviceName(): Promise<string> {5259   const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);5360   const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];

view file

src/background/relay-adoption-bg.ts+17 −1
@@ -34,7 +34,12 @@3434   deriveRendezvousSecret,3535   generatePairingRendezvousToken,3636 } from "../shared/rendezvous";37-import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37+import {38+  buildLocalIdentity,39+  getDeviceName,40+  getLocalPairingIdentity,41+  rotateDeviceId,42+} from "./device-identity";3843 import { getSyncSettings, relayInForce } from "./sync-settings";3944 import { activateLicense, getLicenseKey, isProUser } from "./license";4045 import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";@@ -434,6 +439,17 @@434439       },435440       async deriveLocalIdentity() {436441         // Direct call for the same reason as applyLicense above.442+        const masterKeyHex = getRootKeyHex();443+        if (!masterKeyHex) throw new Error("identity_unavailable");444+        const masterKey = hexToBytes(masterKeyHex);445+        try {446+          return await buildLocalIdentity(masterKey);447+        } finally {448+          masterKey.fill(0);449+        }450+      },451+      async rotateLocalIdentity() {452+        await rotateDeviceId();437453         const masterKeyHex = getRootKeyHex();438454         if (!masterKeyHex) throw new Error("identity_unavailable");439455         const masterKey = hexToBytes(masterKeyHex);

view file

src/shared/vault-adopt.test.ts+50 −0
@@ -109,6 +109,56 @@109109     expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");110110   });111111 112+  it("T-A10c: rotates a copied browser identity when the source reports a device ID collision", async () => {113+    const [receiverChannel, sourceChannel] = memoryPair();114+    const rootKeyHex = "13".repeat(32);115+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));116+    const copiedIdentity = identity("chrome");117+    const freshIdentity = identity("firefox");118+    const rotateLocalIdentity = vi.fn(async () => freshIdentity);119+    const receiverDeps: AdoptionReceiverDeps = {120+      deviceName: "Firefox",121+      generateDeviceId: () => "receiver-ephemeral",122+      confirmSas: vi.fn(async () => true),123+      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),124+      deriveLocalIdentity: vi.fn(async () => copiedIdentity),125+      rotateLocalIdentity,126+      upsertPairedDevice: vi.fn(),127+      onError: vi.fn(),128+    };129+130+    const receiverPromise = requestAdoption(receiverChannel, receiverDeps);131+    await sourceChannel.receive(); // ADOPT_REQUEST132+    await sourceChannel.receive(); // ADOPT_CONFIRM133+    await sendFrame(sourceChannel, {134+      type: "ADOPT_GRANT",135+      root_key_hex: rootKeyHex,136+      vault_id: vaultId,137+      license: "PPK-test",138+      source: {139+        device_id: "android-id",140+        device_name: "Android",141+        device_type: "android",142+        long_term_public_key_hex: "ef".repeat(32),143+      },144+    });145+146+    const firstIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));147+    expect(firstIdentity.device_id).toBe("chrome-id");148+    await sendFrame(sourceChannel, {149+      type: "ADOPT_REJECT",150+      reason: "ADOPT_DEVICE_ID_COLLISION",151+    });152+    const secondIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));153+    expect(secondIdentity.device_id).toBe("firefox-id");154+    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });155+    await sourceChannel.receive(); // ADOPT_APPLIED156+157+    expect(await receiverPromise).not.toBeNull();158+    expect(rotateLocalIdentity).toHaveBeenCalledOnce();159+    expect(receiverDeps.onError).not.toHaveBeenCalled();160+  });161+112162   it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {113163     const [receiverChannel, sourceChannel] = memoryPair();114164     const rootKeyHex = "12".repeat(32);

view file

src/shared/vault-adopt.ts+22 −4
@@ -72,7 +72,7 @@7272 /** Neither peer could prove a Pro entitlement, so no vault state may commit. */7373 export interface AdoptReject {7474   type: "ADOPT_REJECT";75-  reason: "ADOPT_PRO_REQUIRED";75+  reason: "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";7676 }7777 7878 /**@@ -255,6 +255,7 @@255255   validateSource?(source: AdoptGrant["source"]): Promise<void>;256256   createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257257   deriveLocalIdentity(): Promise<LocalIdentity>;258+  rotateLocalIdentity?(): Promise<LocalIdentity>;258259   upsertPairedDevice(device: PairedDevice): Promise<void>;259260   applyLicense?(license: string): Promise<void>;260261   /** Return this receiver's locally validated Pro key, if present. */@@ -335,7 +336,8 @@335336 export function isAdoptReject(frame: unknown): frame is AdoptReject {336337   const value = frame as Partial<AdoptReject> | null;337338   return value?.type === "ADOPT_REJECT" &&338-    value.reason === "ADOPT_PRO_REQUIRED";339+    (value.reason === "ADOPT_PRO_REQUIRED" ||340+      value.reason === "ADOPT_DEVICE_ID_COLLISION");339341 }340342 341343 export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {@@ -651,7 +653,7 @@651653     await deps.applyLicense?.(frame.license);652654   }653655 654-  const identity = await deps.deriveLocalIdentity();656+  let identity = await deps.deriveLocalIdentity();655657   await sendFrame(channel, {656658     type: "ADOPT_IDENTITY",657659     device_id: identity.deviceId,@@ -661,7 +663,23 @@661663     signing_public_key_hex: identity.signingPublicKeyHex,662664   });663665 664-  const completion = await receiveFrame(channel);666+  let completion = await receiveFrame(channel);667+  if (668+    isAdoptReject(completion) &&669+    completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&670+    deps.rotateLocalIdentity671+  ) {672+    identity = await deps.rotateLocalIdentity();673+    await sendFrame(channel, {674+      type: "ADOPT_IDENTITY",675+      device_id: identity.deviceId,676+      device_name: identity.deviceName,677+      device_type: identity.deviceType,678+      long_term_public_key_hex: identity.longTermPublicKeyHex,679+      signing_public_key_hex: identity.signingPublicKeyHex,680+    });681+    completion = await receiveFrame(channel);682+  }665683   if (!isAdoptComplete(completion)) {666684     deps.onError("ADOPT_COMPLETION_MISSING");667685     return null;

view file