651df90f45b2
fix: complete direct receiver adoption and rollback (v4.3.19)
- time
- 2026-07-28 21:17 UTC · 3 months ago
- machine
- koh2
- agent
- gpt-5
- session
- inverse adoption receiver repair
- parent
- b7d66c79afb4
↓ peach-browser-651df90f.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+59 −79src/background/relay-adoption-source-bg.ts+33 −19src/background/vault-createVaultFromRootKey.test.ts+22 −0src/background/vault.ts+22 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.3.18",4+ "version": "4.3.19",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.3.18",4+ "version": "4.3.19",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.3.18",3+ "version": "4.3.19",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/relay-adoption-source-bg.test.ts+59 −79
@@ -11,7 +11,7 @@1111 createVault: vi.fn(),1212 activateLicense: vi.fn(),1313 localIdentity: vi.fn(),14- completePairing: vi.fn(),14+ rollbackVault: vi.fn(),1515 sasDecisions: [] as Array<{ id: string; ok: boolean }>,1616 }));1717 @@ -50,6 +50,7 @@5050 publicKey: new Uint8Array(32).fill(8),5151 }),5252 getDeviceName: mocks.getDeviceName,53+ buildLocalIdentity: mocks.localIdentity,5354 }));5455 5556 vi.mock("./sync-settings", async (importOriginal) => ({@@ -63,11 +64,14 @@6364 vi.mock("./license", () => ({6465 isProUser: vi.fn(async () => false),6566 getLicenseKey: vi.fn(async () => undefined),67+ activateLicense: mocks.activateLicense,6668 }));6769 6870 vi.mock("./vault", () => ({6971 isUnlocked: vi.fn(() => true),7072 getRootKeyHex: mocks.getRootKeyHex,73+ createVaultFromRootKey: mocks.createVault,74+ rollbackAdoptedVault: mocks.rollbackVault,7175 }));7276 7377 vi.mock("../shared/sync-identity", () => ({@@ -353,22 +357,19 @@353357 mocks.getDeviceName.mockReset();354358 mocks.getDeviceName.mockResolvedValue("Receiver Browser");355359 mocks.createVault.mockReset();356- mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });360+ mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });357361 mocks.activateLicense.mockReset();358- mocks.activateLicense.mockResolvedValue({ ok: true });362+ mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });359363 mocks.localIdentity.mockReset();360364 mocks.localIdentity.mockResolvedValue({361- ok: true,362- data: {363- deviceId: "receiver-id",364- deviceName: "Receiver Browser",365- deviceType: "extension",366- longTermPublicKeyHex: "11".repeat(32),367- signingPublicKeyHex: "22".repeat(32),368- },365+ deviceId: "receiver-id",366+ deviceName: "Receiver Browser",367+ deviceType: "extension",368+ longTermPublicKeyHex: "11".repeat(32),369+ signingPublicKeyHex: "22".repeat(32),369370 });370- mocks.completePairing.mockReset();371- mocks.completePairing.mockResolvedValue({ ok: true });371+ mocks.rollbackVault.mockReset();372+ mocks.rollbackVault.mockResolvedValue(undefined);372373 mocks.upsert.mockReset();373374 mocks.upsert.mockResolvedValue(undefined);374375 mocks.triggerSync.mockReset();@@ -379,22 +380,6 @@379380 value: {380381 runtime: {381382 sendMessage(message: { type: string; payload?: unknown }) {382- // The receiver auto-accepts the SAS — no popup decision to mock.383- if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {384- return Promise.resolve(mocks.createVault(message.payload));385- }386- if (message.type === "ACTIVATE_LICENSE") {387- return Promise.resolve(mocks.activateLicense(message.payload));388- }389- if (message.type === "GET_LOCAL_IDENTITY") {390- return Promise.resolve(mocks.localIdentity());391- }392- if (message.type === "COMPLETE_PAIRING") {393- return Promise.resolve(mocks.completePairing(message.payload));394- }395- if (message.type === "TRIGGER_SYNC") {396- return Promise.resolve({ ok: true });397- }398383 mocks.pushes.push({ type: message.type, payload: message.payload });399384 return Promise.resolve({ ok: true });400385 },@@ -446,6 +431,7 @@446431 { type: "ADOPT_COMPLETE" },447432 ]);448433 mocks.openSocket.mockResolvedValue(socket);434+ mocks.createVault.mockResolvedValue({ vaultId });449435 450436 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");451437 Object.defineProperty(globalThis, "chrome", {@@ -453,26 +439,6 @@453439 value: {454440 runtime: {455441 sendMessage(message: { type: string; payload?: unknown }) {456- if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {457- return Promise.resolve({ ok: true, data: { vaultId } });458- }459- if (message.type === "GET_LOCAL_IDENTITY") {460- return Promise.resolve({461- ok: true,462- data: {463- deviceId: "receiver-id",464- deviceName: "Receiver Browser",465- deviceType: "extension",466- longTermPublicKeyHex: "11".repeat(32),467- },468- });469- }470- if (message.type === "COMPLETE_PAIRING") {471- return Promise.resolve({ ok: true });472- }473- if (message.type === "TRIGGER_SYNC") {474- return Promise.resolve({ ok: true });475- }476442 mocks.pushes.push({ type: message.type, payload: message.payload });477443 return Promise.resolve({ ok: true });478444 },@@ -528,7 +494,7 @@528494 },529495 { type: "ADOPT_COMPLETE" },530496 ];531- mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });497+ mocks.createVault.mockResolvedValue({ vaultId });532498 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));533499 534500 // Replace the chrome.runtime mock for this one test. The receiver@@ -538,21 +504,6 @@538504 value: {539505 runtime: {540506 sendMessage(message: { type: string; payload?: unknown }) {541- if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {542- return Promise.resolve(mocks.createVault(message.payload));543- }544- if (message.type === "ACTIVATE_LICENSE") {545- return Promise.resolve(mocks.activateLicense(message.payload));546- }547- if (message.type === "GET_LOCAL_IDENTITY") {548- return Promise.resolve(mocks.localIdentity());549- }550- if (message.type === "COMPLETE_PAIRING") {551- return Promise.resolve(mocks.completePairing(message.payload));552- }553- if (message.type === "TRIGGER_SYNC") {554- return Promise.resolve({ ok: true });555- }556507 mocks.pushes.push({ type: message.type, payload: message.payload });557508 return Promise.resolve({ ok: true });558509 },@@ -572,26 +523,55 @@572523 // The receiver auto-accepted the SAS, so no ADOPTION_SOURCE_SAS push is573524 // expected — only the terminal result matters.574525 575- // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup576- // supplied and the root key the phone granted.577- expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({578- password: "receiver-password",579- rootKeyHex,526+ // The vault module was called directly with the popup-supplied password527+ // and the root key granted by the phone.528+ expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);529+530+ // License activation, identity derivation, and pairing persistence all531+ // call their modules directly from the service worker.532+ expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");533+ expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));534+ expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({535+ deviceId: "phone-source-id",580536 }));537+ expect(mocks.rollbackVault).not.toHaveBeenCalled();581538 582- // License activation, identity derivation, and pairing completion all583- // ran through the standard background handlers.584- expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });585- expect(mocks.localIdentity).toHaveBeenCalled();586- expect(mocks.completePairing).toHaveBeenCalled();587- const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as588- { paired?: { deviceId?: string; deviceName?: string } };589- expect(pairedCall.paired?.deviceId).toBe("phone-source-id");590-591539 // The terminal state is "complete" — the popup can transition to success.592540 expect(mocks.pushes).toContainEqual(expect.objectContaining({593541 type: "ADOPTION_SOURCE_RESULT",594542 payload: expect.objectContaining({ status: "complete" }),543+ }));544+ });545+546+ it("rolls back a newly created vault when a later adoption step fails", async () => {547+ const rootKeyHex = "f0".repeat(32);548+ const vaultId = "b".repeat(64);549+ mocks.createVault.mockResolvedValue({ vaultId });550+ mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));551+ mocks.openSocket.mockResolvedValue(socketWithFrames([552+ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },553+ {554+ type: "ADOPT_GRANT",555+ root_key_hex: rootKeyHex,556+ vault_id: vaultId,557+ source: {558+ device_id: "phone-source-id",559+ device_name: "June's phone",560+ device_type: "android",561+ long_term_public_key_hex: "99".repeat(32),562+ },563+ },564+ ]));565+566+ const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");567+ await startRelayAdoptionReceiver("receiver-password");568+ await waitForPush("ADOPTION_SOURCE_RESULT");569+570+ expect(mocks.rollbackVault).toHaveBeenCalledOnce();571+ expect(mocks.upsert).not.toHaveBeenCalled();572+ expect(mocks.pushes).toContainEqual(expect.objectContaining({573+ type: "ADOPTION_SOURCE_RESULT",574+ payload: expect.objectContaining({ status: "error", error: "identity_failed" }),595575 }));596576 });597577 });src/background/relay-adoption-source-bg.ts+33 −19
@@ -28,10 +28,10 @@2828 deriveRendezvousSecret,2929 generatePairingRendezvousToken,3030 } from "../shared/rendezvous";31-import { getDeviceName, getLocalPairingIdentity } from "./device-identity";31+import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";3232 import { getSyncSettings, relayInForce } from "./sync-settings";33-import { getLicenseKey, isProUser } from "./license";34-import { createVaultFromRootKey, getRootKeyHex, isUnlocked } from "./vault";33+import { activateLicense, getLicenseKey, isProUser } from "./license";34+import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";3535 import { computeVaultId } from "../shared/sync-identity";3636 import { upsertPairedDevice } from "./paired-devices";3737 import { triggerSyncIfReachable } from "./sync-trigger";@@ -283,6 +283,7 @@283283 const ephemeralPrivate = ephemeral.secretKey;284284 const ephemeralPublic = ephemeral.publicKey;285285 let noise: NoiseNKSession | undefined;286+ let createdVault = false;286287 try {287288 const hello = decodeJson<{ type?: unknown; token?: unknown }>(288289 await socket.receive(),@@ -328,28 +329,28 @@328329 // as the vault module. Round-tripping through the message bus was329330 // the root cause of "create_vault_no_response".330331 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);332+ createdVault = true;331333 return { vaultId };332334 },333335 async applyLicense(license) {334- const response = (await chrome.runtime.sendMessage({335- type: "ACTIVATE_LICENSE",336- payload: { key: license },337- })) as { ok: boolean; error?: string };338- if (!response.ok) throw new Error(response.error ?? "license_activation_failed");336+ // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self337+ // issue as createVaultFromRootKey. SW can't reach its own listener.338+ await activateLicense(license);339339 },340340 async deriveLocalIdentity() {341- const response = (await chrome.runtime.sendMessage({342- type: "GET_LOCAL_IDENTITY",343- })) as { ok: boolean; data?: Record<string, unknown>; error?: string };344- if (!response.ok) throw new Error(response.error ?? "identity_unavailable");345- return response.data as never;341+ // Direct call for the same reason as applyLicense above.342+ const masterKeyHex = getRootKeyHex();343+ if (!masterKeyHex) throw new Error("identity_unavailable");344+ const masterKey = hexToBytes(masterKeyHex);345+ try {346+ return await buildLocalIdentity(masterKey);347+ } finally {348+ masterKey.fill(0);349+ }346350 },347351 async upsertPairedDevice(paired) {348- const response = (await chrome.runtime.sendMessage({349- type: "COMPLETE_PAIRING",350- payload: { paired },351- })) as { ok: boolean; error?: string };352- if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");352+ // Direct call for the same reason as applyLicense above.353+ await upsertPairedDevice(paired);353354 },354355 pairState: {355356 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),@@ -361,6 +362,10 @@361362 });362363 363364 if (!device) {365+ if (createdVault) {366+ await rollbackAdoptedVault();367+ createdVault = false;368+ }364369 push("ADOPTION_SOURCE_RESULT", {365370 status: "error",366371 error: "adoption_rejected",@@ -368,8 +373,17 @@368373 return;369374 }370375 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });376+ createdVault = false;371377 void triggerSyncIfReachable({ reason: "manual", force: true });372378 } catch (error) {379+ if (createdVault) {380+ try {381+ await rollbackAdoptedVault();382+ createdVault = false;383+ } catch (rollbackError) {384+ console.error("[Peach] rollback failed:", rollbackError);385+ }386+ }373387 console.error("[Peach] receiver session failed:", error);374388 push("ADOPTION_SOURCE_RESULT", {375389 status: "error",@@ -455,4 +469,4 @@455469 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);456470 }457471 return out;458-}\ No newline at end of file472+}src/background/vault-createVaultFromRootKey.test.ts+22 −0
@@ -177,4 +177,26 @@177177 expect((commitCall![0] as StorageShape).peach_vault_b).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);178178 expect((commitCall![0] as StorageShape).peach_last_modified_at).toEqual(expect.any(String));179179 });180+181+ it("rolls back both stored slots and clears the unlocked adopted vault", async () => {182+ const mock = makeChromeStorageMock();183+ vi.stubGlobal("chrome", mock.chrome);184+ const vault = await import("./vault");185+186+ await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "99".repeat(32));187+ expect(vault.isUnlocked()).toBe(true);188+ expect(mock.storage.peach_vault_a).toBeDefined();189+ expect(mock.storage.peach_vault_b).toBeDefined();190+ expect(mock.storage.peach_last_modified_at).toBeDefined();191+192+ await vault.rollbackAdoptedVault();193+194+ expect(vault.isUnlocked()).toBe(false);195+ expect(vault.getRootKeyHex()).toBeNull();196+ expect(() => vault.getEntries()).toThrow("vault_locked");197+ expect(await vault.hasVault()).toBe(false);198+ expect(mock.storage.peach_vault_a).toBeUndefined();199+ expect(mock.storage.peach_vault_b).toBeUndefined();200+ expect(mock.storage.peach_last_modified_at).toBeUndefined();201+ });180202 });src/background/vault.ts+22 −0
@@ -615,6 +615,28 @@615615 }616616 }617617 618+/**619+ * Roll back a vault that was created by [createVaultFromRootKey] but620+ * couldn't be paired (e.g. the subsequent license activation, identity621+ * derivation, or paired-device upsert failed). Without this, a failed622+ * receiver session leaves behind an empty vault — the user has to623+ * re-pair to get a working setup, but the empty vault sits in the way.624+ *625+ * The call is idempotent: removing keys that aren't there is a no-op.626+ */627+export async function rollbackAdoptedVault(): Promise<void> {628+ lock();629+ try {630+ await chrome.storage.local.remove([631+ SLOT_A,632+ SLOT_B,633+ "peach_last_modified_at",634+ ]);635+ } catch (err) {636+ throw mapStorageError(err);637+ }638+}639+618640 async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {619641 const raw = await chrome.storage.local.get("peach_last_modified_at");620642 const previous = Number(