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
↓ peach-browser-6a9591d9.tar.gzatlas at this save
files changed9 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 −1manifest.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": {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",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": {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 };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>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;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>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>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.