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

view file

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

view file

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

view file

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

view file

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>

view file

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(

view file