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
↓ peach-browser-c4e8e0b7.tar.gzatlas at this save
files changed10 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 −0manifest.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": {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",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",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 {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();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"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(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>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">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",