6a9591d914b5

Pro-gate Duress Vault (SETUP_DURESS server gate + setup-button UI redirect) and Canary Entries (UPDATE_ENTRY server gate on first canary enable + toggle UI redirect via onProGate callback). Add ADVANCED SECURITY section header + 2 cells to upgrade screen feature grid. HIBP/canary alert path noted as unconditional. Bump 3.6.17

time
2026-05-18 23:24 UTC · 5 months ago
machine
koh2
parent
a622b79eacb5
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+22 −0src/popup/App.tsx+10 −0src/popup/styles/popup.css+11 −0src/popup/views/Entry.tsx+35 −5src/popup/views/Settings.tsx+71 −5src/shared/types.ts+3 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.16",5+  "version": "3.6.17",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.16",4+  "version": "3.6.17",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": "3.6.16",3+  "version": "3.6.17",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+22 −0
@@ -363,6 +363,18 @@363363         if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {364364           return { ok: false, error: "invalid_payload" };365365         }366+        // Single enforcement point for the Canary Entries Pro gate. Only367+        // gates the transition into a canary entry — clearing/disabling a368+        // canary (`canary: undefined`) is always allowed, so existing369+        // canary entries remain manageable.370+        const incomingCanary = (patch as { canary?: unknown }).canary;371+        if (incomingCanary && typeof incomingCanary === "object") {372+          const existing = vault.getEntryById(id);373+          if (!existing?.canary) {374+            const gate = await requirePro("canary_entry");375+            if (!gate.allowed) return { ok: false, error: "pro_required" };376+          }377+        }366378         await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);367379         return { ok: true };368380       }@@ -605,6 +617,10 @@605617       // rebinding case. The UI under CanarySection (Entry.tsx) discloses to606618       // users that the entry title leaves the device when the canary fires.607619       case "FIRE_CANARY_PING": {620+        // Canary alert fires unconditionally for any entry with canary: true.621+        // The Pro gate only governs creation/flagging of canary entries; once622+        // an entry is flagged, the alert path is a security property and must623+        // never be license-gated.608624         const { webhookUrl, entryTitle, trigger } = msg.payload as {609625           webhookUrl: string;610626           entryTitle: string;@@ -738,6 +754,12 @@738754         return { ok: true, data: enabled };739755       }740756       case "SETUP_DURESS": {757+        // Single enforcement point for the Duress Vault Pro gate. Free users758+        // who somehow reach this handler are rejected here; the popup also759+        // pre-checks isPro() to avoid showing the setup form to free users,760+        // but server-side is the actual security boundary.761+        const gate = await requirePro("duress_vault");762+        if (!gate.allowed) return { ok: false, error: "pro_required" };741763         const { duressPassword } = msg.payload as { duressPassword: string };742764         await vault.setupDuress(duressPassword);743765         return { ok: true };

view file

src/popup/App.tsx+10 −0
@@ -1211,9 +1211,15 @@12111211                   entry={selectedEntry()}12121212                   currentTabUrl={currentTabUrl()}12131213                   entries={entries()}1214+                  isPro={proStatus()}12141215                   onDelete={handleDelete}12151216                   onFieldSaved={() => void loadEntries()}12161217                   onEscape={() => { setView("list"); }}1218+                  onProGate={(feature) => {1219+                    setUpgradeHighlight(feature);1220+                    setSettingsInitialSection("license");1221+                    setView("settings");1222+                  }}12171223                 />12181224               </div>12191225 @@ -1364,6 +1370,10 @@13641370                   onOpenPanic={() => setView("panic")}13651371                   onOpenCodexGenerate={() => setView("codex-generate")}13661372                   onOpenCodexRestore={() => setView("codex-restore")}1373+                  onProGate={(feature) => {1374+                    setUpgradeHighlight(feature);1375+                    setSettingsInitialSection("license");1376+                  }}13671377                 />13681378               </Suspense>13691379             </Show>

view file

src/popup/styles/popup.css+11 −0
@@ -5988,6 +5988,17 @@59885988   color: var(--peach-t1);59895989 }59905990 5991+/* Group header for the Pro feature grid (e.g. "ADVANCED SECURITY"). */5992+.peach-pro-section-label {5993+  margin: 6px 0 0;5994+  font-family: var(--font-ui);5995+  font-size: 0.5625rem;5996+  font-weight: 500;5997+  letter-spacing: 0.12em;5998+  text-transform: uppercase;5999+  color: var(--peach-t2);6000+}6001+59916002 /* 2-column numbered feature grid */59926003 .peach-pro-grid {59936004   display: grid;

view file

src/popup/views/Entry.tsx+35 −5
@@ -1,5 +1,5 @@11 import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2-import type { VaultEntry } from "../../shared/types";2+import type { ProFeature, VaultEntry } from "../../shared/types";33 import { isPasskeyOnly } from "../../shared/types";44 import { detectBrowser } from "../../shared/browser";55 import { getDisplayDomain } from "../../shared/domain";@@ -26,6 +26,12 @@2626   onFieldSaved: () => void | Promise<void>;2727   onEscape?: () => void;2828   entries?: VaultEntry[];29+  /** Whether the user holds an active Peach Pro license. Drives Pro gates on30+   *  canary entries (see CanarySection). */31+  isPro?: boolean;32+  /** Invoked when a Pro-gated control is tapped on free tier. App owns the33+   *  upgrade navigation + highlight state. */34+  onProGate?: (feature: ProFeature) => void;2935 }3036 3137 export function EntryView(props: EntryProps) {@@ -1339,7 +1345,7 @@13391345                 </button>13401346                 <Show when={showAdvanced()}>13411347                   <Show when={!isPasskeyOnly(entry())}>1342-                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1348+                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />13431349                   </Show>13441350 13451351                   <Show when={entry().passkey}>@@ -1520,13 +1526,21 @@15201526   );15211527 }15221528 1523-function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1529+function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {15241530   const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));15251531   const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");15261532   const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");15271533   const [testResult, setTestResult] = createSignal<string | null>(null);15281534   const [testing, setTesting] = createSignal(false);15291535 1536+  // Pro gate entry-point for canary creation. Free users cannot toggle the1537+  // canary on; the server-side UPDATE_ENTRY handler is the single hard1538+  // enforcement point, but this branch avoids the bad UX of letting the1539+  // toggle slip into "on" before the save rejects. Existing canary entries1540+  // (e.g. retained after a license deactivation, future-proofing) remain1541+  // togglable off — gating only applies to enabling a new canary.1542+  const isProGated = () => !props.isPro && !props.entry.canary;1543+15301544   async function save() {15311545     const url = webhookUrl().trim();15321546     const nextLabel = label().trim() || undefined;@@ -1561,11 +1575,27 @@15611575   return (15621576     <div class="peach-canary-section">15631577       <div class="peach-field-value-row">1564-        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1578+        <span class="peach-field-value" style="font-size:12px;">1579+          canary alert1580+          <Show when={isProGated()}>1581+            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1582+          </Show>1583+        </span>15651584         <button15661585           class={`peach-toggle ${enabled() ? "is-on" : ""}`}1567-          onClick={() => { setEnabled(!enabled()); void save(); }}1586+          onClick={() => {1587+            if (isProGated()) {1588+              // Pro gate: tap on free tier navigates to the upgrade screen1589+              // with Canary Entries highlighted. The toggle never flips on1590+              // for a free user, regardless of click count.1591+              props.onProGate?.("canary_entry");1592+              return;1593+            }1594+            setEnabled(!enabled());1595+            void save();1596+          }}15681597           aria-label="Toggle canary"1598+          aria-disabled={isProGated() ? "true" : "false"}15691599         >15701600           <span class="peach-toggle-knob" />15711601         </button>

view file

src/popup/views/Settings.tsx+71 −5
@@ -62,6 +62,10 @@6262   onOpenPanic?: () => void;6363   onOpenCodexGenerate?: () => void;6464   onOpenCodexRestore?: () => void;65+  /** Invoked from within Settings when a Pro-gated control is tapped on66+   *  free tier. App owns the upgrade navigation + highlight state, so this67+   *  callback is the single way for Settings to request that navigation. */68+  onProGate?: (feature: ProFeature) => void;6569 }6670 6771 type SectionName =@@ -774,10 +778,20 @@774778             </div>775779           </Show>776780 777-          <p class="peach-settings-sub-label">DURESS PASSWORD</p>781+          <p class="peach-settings-sub-label">782+            DURESS PASSWORD783+            <Show when={!isPro()}>784+              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>785+            </Show>786+          </p>778787           <div class="peach-settings-row peach-settings-row--top">779788             <div class="peach-settings-row-main">780-              <span>Duress password</span>789+              <span>790+                Duress password791+                <Show when={!isPro()}>792+                  <span class="peach-pro-badge">Pro</span>793+                </Show>794+              </span>781795               <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">782796                 If entered under coercion, shows a decoy empty vault instead of your real one.783797               </span>@@ -805,7 +819,24 @@805819             <div class="peach-settings-row peach-settings-row--meta">806820               <button807821                 class="peach-btn peach-btn--subtle peach-btn--sm"808-                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}822+                onClick={() => {823+                  // Pro gate entry-point: free users are redirected to the824+                  // upgrade screen before the setup form opens. The server-825+                  // side SETUP_DURESS handler is the single enforcement826+                  // point; this is UI redirection so the form never appears827+                  // for a user who can't complete it.828+                  if (!isPro() && !duressEnabled()) {829+                    props.onProGate?.("duress_vault");830+                    setActiveSection("license");831+                    return;832+                  }833+                  setShowDuressSetup(true);834+                  setDuressError(null);835+                  setDuressSuccess(null);836+                  setDuressPassword("");837+                  setDuressConfirm("");838+                  setDuressMainPassword("");839+                }}809840               >810841                 {duressEnabled() ? "Change / disable" : "Set up"}811842               </button>@@ -878,10 +909,20 @@878909             </div>879910           </Show>880911 881-          <p class="peach-settings-sub-label">CANARY ENTRIES</p>912+          <p class="peach-settings-sub-label">913+            CANARY ENTRIES914+            <Show when={!isPro()}>915+              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>916+            </Show>917+          </p>882918           <div class="peach-settings-row peach-settings-row--top">883919             <div class="peach-settings-row-main">884-              <span>Canary entries</span>920+              <span>921+                Canary entries922+                <Show when={!isPro()}>923+                  <span class="peach-pro-badge">Pro</span>924+                </Show>925+              </span>885926               <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">886927                 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.887928               </span>@@ -1402,6 +1443,15 @@14021443     { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },14031444   ];14041445 1446+  const PRO_SECURITY_FEATURES: Array<{1447+    feature: ProFeature;1448+    name: string;1449+    desc: string;1450+  }> = [1451+    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1452+    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1453+  ];1454+14051455   const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14061456   const [showKey, setShowKey] = createSignal(false);14071457 @@ -1460,6 +1510,22 @@14601510                           classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}14611511                         >14621512                           <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1513+                          <span class="peach-pro-grid-name">{item.name}</span>1514+                          <span class="peach-pro-grid-desc">{item.desc}</span>1515+                        </div>1516+                      )}1517+                    </For>1518+                  </div>1519+1520+                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1521+                  <div class="peach-pro-grid">1522+                    <For each={PRO_SECURITY_FEATURES}>1523+                      {(item, i) => (1524+                        <div1525+                          class="peach-pro-grid-item"1526+                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1527+                        >1528+                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>14631529                           <span class="peach-pro-grid-name">{item.name}</span>14641530                           <span class="peach-pro-grid-desc">{item.desc}</span>14651531                         </div>

view file

src/shared/types.ts+3 −1
@@ -335,7 +335,9 @@335335   | "advanced_health"336336   | "smart_password_change"337337   | "ios_app"338-  | "codex";338+  | "codex"339+  | "canary_entry"340+  | "duress_vault";339341 340342 /**341343  * License export shape included in PeachScript and encrypted JSON backups.

view file