158d6d9fe504
say plainly at the point of removal that revocation stops future changes but recovery words survive it (v4.1.6)
- time
- 2026-07-26 08:42 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync campaign remaining items
- parent
- 0dcc53fd9e7f
↓ peach-browser-158d6d9f.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+16 −0src/popup/views/Settings.tsx+21 −7src/popup/views/contracts.test.tsx+47 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.5",4+ "version": "4.1.6",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.1.5",4+ "version": "4.1.6",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.1.5",3+ "version": "4.1.6",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/styles/popup.css+16 −0
@@ -10452,3 +10452,19 @@1045210452 0 14px 48px rgba(80, 56, 32, 0.10),1045310453 0 0 0 1px rgba(26, 18, 8, 0.04);1045410454 }10455+10456+/* HOLE 4: the revoke confirmation carries its own disclosure, so it needs the10457+ full row width rather than sitting inline beside the device name. */10458+.peach-settings-row:has(> .peach-unpair-confirm) { flex-wrap: wrap; }10459+.peach-unpair-confirm {10460+ display: flex;10461+ flex-direction: column;10462+ gap: 8px;10463+ width: 100%;10464+}10465+.peach-unpair-confirm-note {10466+ margin: 0;10467+ font-size: 0.6875rem;10468+ line-height: 1.5;10469+ color: var(--peach-t2);10470+}src/popup/views/Settings.tsx+21 −7
@@ -1669,13 +1669,27 @@16691669 >remove</button>16701670 }16711671 >1672- <div style="display:flex;gap:6px;flex-shrink:0">1673- <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1674- <button1675- class="peach-btn peach-btn--sm"1676- style="background:var(--peach-breach);border-color:var(--peach-breach)"1677- onClick={() => void unpairDevice(d.deviceId)}1678- >remove</button>1672+ {/* HOLE 4: revocation stops this device receiving future1673+ changes. It does NOT secure the vault against it — that1674+ device may already hold the recovery words, which are1675+ master-password-equivalent and restore the whole vault1676+ anywhere, forever, with no sync involved. Say so here,1677+ rather than leaving the user falsely reassured. */}1678+ <div class="peach-unpair-confirm">1679+ <p class="peach-unpair-confirm-note">1680+ Stops this device receiving future changes. It doesn't1681+ lock it out of what it already has — if it's lost or1682+ stolen, also change your master password and generate1683+ new recovery words.1684+ </p>1685+ <div style="display:flex;gap:6px;justify-content:flex-end">1686+ <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1687+ <button1688+ class="peach-btn peach-btn--sm"1689+ style="background:var(--peach-breach);border-color:var(--peach-breach)"1690+ onClick={() => void unpairDevice(d.deviceId)}1691+ >remove</button>1692+ </div>16791693 </div>16801694 </Show>16811695 </div>src/popup/views/contracts.test.tsx+47 −0
@@ -406,6 +406,53 @@406406 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);407407 });408408 409+ it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {410+ const device = {411+ deviceId: "phone-1",412+ deviceName: "June's Phone",413+ deviceType: "android" as const,414+ longTermPublicKeyHex: "a".repeat(64),415+ signingPublicKeyHex: "b".repeat(64),416+ pairedAt: 1,417+ trustSource: "direct" as const,418+ };419+ sendMessageMock.mockImplementation(async (msg: unknown) => {420+ const m = msg as { type: string };421+ if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };422+ return { ok: true, data: null };423+ });424+425+ const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);426+ for (let i = 0; i < 30; i++) await Promise.resolve();427+428+ const openSync = Array.from(container.querySelectorAll("button")).find((b) =>429+ /sync/i.test(b.textContent ?? ""),430+ );431+ openSync?.click();432+ for (let i = 0; i < 30; i++) await Promise.resolve();433+434+ const remove = Array.from(container.querySelectorAll("button")).find(435+ (b) => (b.textContent ?? "").trim() === "remove",436+ );437+ expect(remove).toBeTruthy();438+439+ // Guard against a vacuous pass: the disclosure must not already be on440+ // screen before the user asks to remove anything.441+ expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();442+443+ remove!.click();444+ for (let i = 0; i < 10; i++) await Promise.resolve();445+446+ // Revocation must never be presented as securing the vault against the447+ // removed device — recovery words survive it and restore everything.448+ const note = container.querySelector(".peach-unpair-confirm-note");449+ expect(note).toBeTruthy();450+ const text = note!.textContent ?? "";451+ expect(text).toContain("master password");452+ expect(text).toContain("recovery words");453+ expect(text).toContain("future changes");454+ });455+409456 it("Settings requires current, new, and confirm passwords before enabling save", async () => {410457 sendMessageMock.mockResolvedValue({ ok: true, data: null });411458 const container = mount(