c4e8e0b71f63

fix: disclose vault trust scope at device approval (PB-26-708, PB-26-709, v5.2.13)

time
2026-09-07 01:28 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
red team remediation
parent
bdcc99ca7c46
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-bg.ts+26 −5src/popup/views/AdoptVaultView.test.tsx+9 −2src/popup/views/AdoptVaultView.tsx+12 −2src/popup/views/PairDevice.test.tsx+3 −0src/popup/views/PairDevice.tsx+6 −1src/popup/views/Settings.tsx+11 −2src/popup/views/contracts.test.tsx+35 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.12",4+  "version": "5.2.13",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.12",5+  "version": "5.2.13",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.12",3+  "version": "5.2.13",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-bg.ts+26 −5
@@ -280,6 +280,7 @@280280         adoptRequest,281281         derivePairingSAS(session.handshakeHash),282282         crypto.randomUUID(),283+        session.handshakeHash,283284       ),284285       upsertPairedDevice: storeAdoptedDevice,285286     });@@ -408,7 +409,7 @@408409       async confirmSas() {409410         const sas = derivePairingSAS(session.handshakeHash);410411         const id = crypto.randomUUID();411-        return waitForReceiverSasConfirmation(id, sas);412+        return waitForReceiverSasConfirmation(id, sas, session.handshakeHash);412413       },413414       async createVaultFromRootKey(rootKeyHex) {414415         // Direct call, not chrome.runtime.sendMessage — a service worker@@ -588,7 +589,15 @@588589  * the deferred the moment it sees the SAS push, and a missing slot would589590  * drop that approval on the floor.590591  */591-function waitForReceiverSasConfirmation(id: string, sas: string): Promise<boolean> {592+function pairingSessionFingerprint(handshakeHash: Uint8Array): string {593+  return bytesToHex(handshakeHash).slice(0, 12).match(/.{4}/g)?.join(" ") ?? "";594+}595+596+function waitForReceiverSasConfirmation(597+  id: string,598+  sas: string,599+  handshakeHash: Uint8Array,600+): Promise<boolean> {592601   return new Promise<boolean>((resolve) => {593602     const timer = setTimeout(() => {594603       pendingReceiverSasConfirmation.delete(id);@@ -598,7 +607,12 @@598607       clearTimeout(timer);599608       resolve(ok);600609     } });601-    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });610+    push("ADOPTION_SOURCE_SAS", {611+      id,612+      sas,613+      displayOnly: false,614+      peerFingerprint: pairingSessionFingerprint(handshakeHash),615+    });602616   });603617 }604618 @@ -606,6 +620,7 @@606620   id: string,607621   request: AdoptRequest,608622   sas: string,623+  handshakeHash: Uint8Array,609624 ): Promise<boolean> {610625   return new Promise<boolean>((resolve) => {611626     const timer = setTimeout(() => {@@ -622,6 +637,7 @@622637       id,623638       sas,624639       receiverDeviceName: request.device_name,640+      peerFingerprint: pairingSessionFingerprint(handshakeHash),625641     });626642   });627643 }@@ -632,8 +648,13 @@632648  * confirmation be the single commit gate. grantAdoption still refuses to send633649  * the root key until the receiver returns ADOPT_CONFIRM(true).634650  */635-function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {636-  return waitForSourceSasConfirmation(id, request, sas);651+function requestConsent(652+  request: AdoptRequest,653+  sas: string,654+  id: string,655+  handshakeHash: Uint8Array,656+): Promise<boolean> {657+  return waitForSourceSasConfirmation(id, request, sas, handshakeHash);637658 }638659 639660 function clearState(): void {

view file

src/popup/views/AdoptVaultView.test.tsx+9 −2
@@ -220,13 +220,20 @@220220     for (const listener of listeners) {221221       listener({222222         type: "ADOPTION_SOURCE_SAS",223-        payload: { id: "receiver-sas-id", sas: "48219371", displayOnly: false },223+        payload: {224+          id: "receiver-sas-id",225+          sas: "48219371",226+          displayOnly: false,227+          peerFingerprint: "a1b2 c3d4 e5f6",228+        },224229       });225230     }226231     await flushView();227232 228233     expect(view.container.textContent).toContain("48219371");229-    expect(view.container.textContent).toContain("Confirm this code matches the one on your phone");234+    expect(view.container.textContent).toContain("Confirm this code and session ID");235+    expect(view.container.textContent).toContain("a1b2 c3d4 e5f6");236+    expect(view.container.textContent).toContain("complete copy of its vault");230237     expect(button(view.container, "Confirm code")).toBeTruthy();231238     expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();232239     expect(view.container.querySelector(".peach-pair-qr")).toBeNull();

view file

src/popup/views/AdoptVaultView.tsx+12 −2
@@ -58,6 +58,7 @@5858   const [strengthScore, setStrengthScore] = createSignal<number | null>(null);5959   const [receiverInvitation, setReceiverInvitation] = createSignal("");6060   const [receiverSas, setReceiverSas] = createSignal("");61+  const [peerFingerprint, setPeerFingerprint] = createSignal("");6162   const [receiverError, setReceiverError] = createSignal("");6263   const [receiverComplete, setReceiverComplete] = createSignal(false);6364   let completionTimer: number | undefined;@@ -111,11 +112,17 @@111112   if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {112113     const handler = (msg: { type?: string; payload?: unknown }) => {113114       if (msg?.type === "ADOPTION_SOURCE_SAS") {114-        const p = msg.payload as { id?: string; sas?: string; displayOnly?: boolean } | undefined;115+        const p = msg.payload as {116+          id?: string;117+          sas?: string;118+          displayOnly?: boolean;119+          peerFingerprint?: string;120+        } | undefined;115121         if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {116122           setReceiverSas(p.sas);117123           setReceiverSasId((p as { id: string }).id);118124           setReceiverSasDecision(false);125+          setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");119126         }120127       } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {121128         const p = msg.payload as {@@ -304,7 +311,10 @@304311                       <strong>{receiverSas()}</strong>305312                     </div>306313                     <p class="peach-adopt-confirmation-help">307-                      Confirm this code matches the one on your phone.314+                      Confirm this code and session ID {peerFingerprint()} match315+                      the phone you intend to trust. Approving replaces this316+                      empty device with a complete copy of its vault, including317+                      existing passwords, passkeys, notes, and recovery access.308318                     </p>309319                     <button310320                       class="peach-btn peach-btn--accent peach-btn--md"

view file

src/popup/views/PairDevice.test.tsx+3 −0
@@ -146,10 +146,13 @@146146       id: "test-sas-id",147147       sas: "48219371",148148       receiverDeviceName: "June's phone",149+      peerFingerprint: "a1b2 c3d4 e5f6",149150     });150151     expect(container.textContent).toContain("June's phone");151152     expect(container.textContent).toContain("48219371");152153     expect(container.textContent).toContain("Compare the 8-digit safety code");154+    expect(container.textContent).toContain("a1b2 c3d4 e5f6");155+    expect(container.textContent).toContain("complete copy of this vault");153156     expect(container.querySelector("#adoption-consent-password")).toBeNull();154157     // The pop-up must NOT auto-approve; it waits for the user to tap a button.155158     expect(mockSendMessage).not.toHaveBeenCalledWith(

view file

src/popup/views/PairDevice.tsx+6 −1
@@ -41,6 +41,7 @@4141   const [sourceSas, setSourceSas] = createSignal("");4242   const [pendingSasId, setPendingSasId] = createSignal("");4343   const [receiverName, setReceiverName] = createSignal("New device");44+  const [peerFingerprint, setPeerFingerprint] = createSignal("");4445   const [shareError, setShareError] = createSignal("");4546   const [changingRelay, setChangingRelay] = createSignal(false);4647 @@ -105,6 +106,7 @@105106             id?: string;106107             sas?: string;107108             receiverDeviceName?: string;109+            peerFingerprint?: string;108110           } | undefined;109111           if (typeof p?.sas === "string") {110112             setSourceSas(p.sas);@@ -114,6 +116,7 @@114116                 ? p.receiverDeviceName115117                 : "New device",116118             );119+            setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");117120             setSharePhase("sas");118121           }119122         } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {@@ -280,7 +283,9 @@280283           <div class="peach-pair-headline">Compare the 8-digit safety code</div>281284           <div class="peach-pair-sub">282285             If {receiverName()} shows the same code, tap “They match” on that283-            device too. Peach will only finish after both devices confirm.286+            device too. Session ID {peerFingerprint()}. Approving gives that287+            device a complete copy of this vault, including existing passwords,288+            passkeys, notes, and recovery access.284289           </div>285290           <div class="peach-adoption-sas" aria-label="Pairing confirmation code">286291             <strong>{sourceSas()}</strong>

view file

src/popup/views/Settings.tsx+11 −2
@@ -243,7 +243,13 @@243243 244244   // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting245245   // explicit confirmation before they become sync targets.246-  interface PendingVouched { deviceId: string; deviceName: string }246+  interface PendingVouched {247+    deviceId: string;248+    deviceName: string;249+    longTermPublicKeyHex: string;250+    receivedAt: number;251+    vouchedByDeviceId: string;252+  }247253   const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);248254   const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);249255   // Paired-device management lives here — Settings is the sync hub.@@ -1757,7 +1763,10 @@17571763                   <div class="peach-settings-row-main">17581764                     <span>{dev.deviceName}</span>17591765                     <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1760-                      Was added by another of your devices. Allow it to sync with this vault?1766+                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1767+                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1768+                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1769+                      existing vault contents and all future changes.17611770                     </span>17621771                   </div>17631772                   <div style="display:flex;gap:6px;flex-shrink:0">

view file

src/popup/views/contracts.test.tsx+35 −0
@@ -717,6 +717,41 @@717717     expect(text).toContain("future changes");718718   });719719 720+  it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {721+    sendMessageMock.mockImplementation(async (msg: unknown) => {722+      const m = msg as { type: string };723+      if (m.type === "GET_PENDING_VOUCHED_DEVICES") {724+        return {725+          ok: true,726+          data: [{727+            deviceId: "phone-source-id",728+            deviceName: "June's Phone",729+            deviceType: "android",730+            longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),731+            signingPublicKeyHex: "b".repeat(64),732+            pairedAt: Date.UTC(2026, 8, 5),733+            receivedAt: Date.UTC(2026, 8, 6, 20, 15),734+            vouchedByDeviceId: "trusted-browser-id",735+          }],736+        };737+      }738+      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };739+      return { ok: true, data: null };740+    });741+742+    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);743+    for (let i = 0; i < 30; i++) await Promise.resolve();744+    Array.from(container.querySelectorAll("button"))745+      .find((button) => /sync/i.test(button.textContent ?? ""))746+      ?.click();747+    for (let i = 0; i < 30; i++) await Promise.resolve();748+749+    expect(container.textContent).toContain("a1b2 c3d4 e5f6");750+    expect(container.textContent).toContain("trusted-browser-id");751+    expect(container.textContent).toContain("2026-09-06");752+    expect(container.textContent).toContain("existing vault contents");753+  });754+720755   it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {721756     const device = {722757       deviceId: "phone-1",

view file