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
↓ peach-browser-68e79607.tar.gzatlas at this save
files changed9 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 −4manifest.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": {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",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",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,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 });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];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);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);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;