61a600251c9b

fix: gate vault adoption on both confirmations (v4.0.46)

time
2026-07-26 04:47 UTC · 3 months ago
machine
koh2
parent
91e964e65906
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+6 −6src/shared/vault-adopt.test.ts+39 −7src/shared/vault-adopt.ts+31 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.45",4+  "version": "4.0.46",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.0.45",4+  "version": "4.0.46",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.0.45",3+  "version": "4.0.46",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/App.tsx+6 −6
@@ -1206,14 +1206,12 @@12061206     const { channel, sas, socket, host } = dial.dialled;12071207 12081208     try {1209-      // Same out-of-band gate as device pairing, and for the same reason: the1210-      // token proves nothing, the user comparing this code does. Ask BEFORE1211-      // requesting the vault's root key.1212-      if (!(await args.confirmSAS(sas))) throw new Error("sas_rejected");1213-12141209       const source = await requestAdoption(channel, {12151210         deviceName: defaultAdoptionDeviceName(),12161211         generateDeviceId: () => crypto.randomUUID(),1212+        // requestAdoption writes ADOPT_CONFIRM and refuses to read a root-key1213+        // grant until this local SAS decision is true.1214+        confirmSas: () => args.confirmSAS(sas),12171215         createVaultFromRootKey: args.createVault,12181216         deriveLocalIdentity: async () => {12191217           const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });@@ -1244,7 +1242,9 @@12441242           if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();12451243         },12461244         onError(error) {1247-          throw new Error(error);1245+          throw new Error(1246+            error === "ADOPT_SAS_REJECTED" ? "sas_rejected" : error,1247+          );12481248         },12491249       });12501250       if (!source) throw new Error("adoption_rejected");

view file

src/shared/vault-adopt.test.ts+39 −7
@@ -57,6 +57,7 @@5757     const receiverDeps: AdoptionReceiverDeps = {5858       deviceName: "Receiver",5959       generateDeviceId: () => "receiver-ephemeral",60+      confirmSas: vi.fn(async () => true),6061       createVaultFromRootKey: vi.fn(async () => ({ vaultId })),6162       deriveLocalIdentity: vi.fn(async () => identity("receiver")),6263       upsertPairedDevice: receiverUpsert,@@ -87,11 +88,33 @@8788   });8889 8990   it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {90-    const onConsent = vi.fn();91-    const channel = memoryPair()[0];92-    await expect(Promise.reject(new Error("sas_rejected"))).rejects.toThrow("sas_rejected");93-    expect(onConsent).not.toHaveBeenCalled();94-    void channel;91+    const [receiverChannel, sourceChannel] = memoryPair();92+    const rootKeyHex = vi.fn(async () => "11".repeat(32));93+    const createVaultFromRootKey = vi.fn();94+    const receiver = requestAdoption(receiverChannel, {95+      deviceName: "Receiver",96+      generateDeviceId: () => "receiver-ephemeral",97+      confirmSas: vi.fn(async () => false),98+      createVaultFromRootKey,99+      deriveLocalIdentity: vi.fn(),100+      upsertPairedDevice: vi.fn(),101+      onError: vi.fn(),102+    });103+    const request = JSON.parse(104+      new TextDecoder().decode(await sourceChannel.receive()),105+    ) as AdoptRequest;106+    const source = grantAdoption(sourceChannel, request, {107+      sourceIdentity: identity("source"),108+      rootKeyHex,109+      vaultId: vi.fn(),110+      onConsent: vi.fn(async () => true),111+      upsertPairedDevice: vi.fn(),112+    });113+114+    await expect(receiver).resolves.toBeNull();115+    await expect(source).resolves.toBeNull();116+    expect(rootKeyHex).not.toHaveBeenCalled();117+    expect(createVaultFromRootKey).not.toHaveBeenCalled();95118   });96119 97120   it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {@@ -101,6 +124,7 @@101124     const receiver = requestAdoption(receiverChannel, {102125       deviceName: "Receiver",103126       generateDeviceId: () => "receiver-ephemeral",127+      confirmSas: vi.fn(async () => true),104128       createVaultFromRootKey,105129       deriveLocalIdentity: vi.fn(),106130       upsertPairedDevice: vi.fn(),@@ -127,6 +151,7 @@127151     const receiver = requestAdoption(receiverChannel, {128152       deviceName: "Receiver",129153       generateDeviceId: () => "receiver-ephemeral",154+      confirmSas: vi.fn(async () => true),130155       createVaultFromRootKey,131156       deriveLocalIdentity: vi.fn(),132157       upsertPairedDevice: vi.fn(),@@ -152,6 +177,7 @@152177     const receiver = requestAdoption(receiverChannel, {153178       deviceName: "Receiver",154179       generateDeviceId: () => "receiver-ephemeral",180+      confirmSas: vi.fn(async () => true),155181       createVaultFromRootKey,156182       deriveLocalIdentity: vi.fn(),157183       upsertPairedDevice: vi.fn(),@@ -171,9 +197,9 @@171197   });172198 173199   it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {174-    const channel = memoryPair()[0];200+    const [receiverChannel, sourceChannel] = memoryPair();175201     const upsert = vi.fn();176-    const promise = grantAdoption(channel, {202+    const promise = grantAdoption(sourceChannel, {177203       type: "ADOPT_REQUEST",178204       device_id: "receiver-ephemeral",179205       device_name: "Receiver",@@ -185,6 +211,10 @@185211       onConsent: vi.fn(async () => true),186212       upsertPairedDevice: upsert,187213     });214+    await sendFrame(receiverChannel, {215+      type: "ADOPT_CONFIRM",216+      confirmed: true,217+    });188218 189219     await Promise.race([promise, Promise.resolve()]);190220     expect(upsert).not.toHaveBeenCalled();@@ -198,6 +228,7 @@198228     const receiver = requestAdoption(receiverChannel, {199229       deviceName: "Receiver",200230       generateDeviceId: () => "receiver-ephemeral",231+      confirmSas: vi.fn(async () => true),201232       createVaultFromRootKey: vi.fn(async () => {202233         throw new Error("commit_failed");203234       }),@@ -225,6 +256,7 @@225256     const receiver = requestAdoption(receiverChannel, {226257       deviceName: "Receiver",227258       generateDeviceId: () => "receiver-ephemeral",259+      confirmSas: vi.fn(async () => true),228260       createVaultFromRootKey,229261       deriveLocalIdentity: vi.fn(),230262       upsertPairedDevice: vi.fn(),

view file

src/shared/vault-adopt.ts+31 −1
@@ -24,6 +24,11 @@2424   };2525 }2626 27+export interface AdoptConfirm {28+  type: "ADOPT_CONFIRM";29+  confirmed: boolean;30+}31+2732 export interface AdoptIdentity {2833   type: "ADOPT_IDENTITY";2934   device_id: string;@@ -33,11 +38,17 @@3338   signing_public_key_hex?: string;3439 }3540 36-export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;41+export type AdoptionFrame =42+  | AdoptRequest43+  | AdoptConfirm44+  | AdoptGrant45+  | AdoptIdentity;3746 3847 export interface AdoptionReceiverDeps {3948   deviceName: string;4049   generateDeviceId(): string;50+  /** Root-key transfer cannot begin until this device confirms the SAS. */51+  confirmSas(): Promise<boolean>;4152   createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;4253   deriveLocalIdentity(): Promise<LocalIdentity>;4354   upsertPairedDevice(device: PairedDevice): Promise<void>;@@ -78,6 +89,12 @@7889     /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);7990 }8091 92+export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {93+  const value = frame as Partial<AdoptConfirm> | null;94+  return value?.type === "ADOPT_CONFIRM" &&95+    typeof value.confirmed === "boolean";96+}97+8198 export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {8299   const value = frame as Partial<AdoptIdentity> | null;83100   return value?.type === "ADOPT_IDENTITY" &&@@ -104,6 +121,16 @@104121     device_type: "extension",105122   });106123 124+  const confirmed = await deps.confirmSas();125+  await sendFrame(channel, {126+    type: "ADOPT_CONFIRM",127+    confirmed,128+  });129+  if (!confirmed) {130+    deps.onError("ADOPT_SAS_REJECTED");131+    return null;132+  }133+107134   const frame = await receiveFrame(channel);108135   if (!isAdoptGrant(frame)) {109136     deps.onError("ADOPT_UNEXPECTED_FRAME");@@ -160,6 +187,9 @@160187 ): Promise<PairedDevice | null> {161188   const approved = await deps.onConsent(request);162189   if (!approved) return null;190+191+  const confirmation = await receiveFrame(channel);192+  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;163193 164194   const rootKeyHex = await deps.rootKeyHex();165195   if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");

view file