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
7 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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 });

view file

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+}

view file

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 });

view file

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(

view file