628eb2704ced

feat: sync no longer Pro-gated + Pro license transfers during pairing — anyone can attempt pairing; a Pro device advertises its signed license in PAIR_IDENTITY and the peer activates it (signature-verified) after SAS confirm, either direction; Settings shows Free/Pro + 'one device needs Pro' note; bump 4.0.12→4.0.13

time
2026-06-19 13:12 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
ungate sync + license transfer
parent
1cd7b3488f2b
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+12 −9src/popup/App.tsx+1 −0src/popup/chirp-pair.ts+30 −3src/popup/views/ChirpSync.tsx+15 −40src/popup/views/Settings.tsx+7 −7src/shared/chirp-handshake.test.ts+18 −0src/shared/chirp-handshake.ts+33 −2
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 Password Manager",5-  "version": "4.0.12",5+  "version": "4.0.13",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 Password Manager",4-  "version": "4.0.12",4+  "version": "4.0.13",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.0.12",3+  "version": "4.0.13",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+12 −9
@@ -1316,9 +1316,9 @@13161316       // drive Phase-1 audio pairing. The private long-term key never13171317       // leaves the background.13181318       case "CHIRP_GET_LOCAL_IDENTITY": {1319+        // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a1320+        // Pro license from an already-Pro peer (at least one device must be Pro).13191321         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1320-        const proCheck = await requirePro("sync");1321-        if (!proCheck.allowed) return { ok: false, error: "pro_required" };13221322         // Identity derives from the stable root key, not the password-derived13231323         // key, so pairing survives a master-password change (SEV-2-B).13241324         const masterKeyHex = vault.getRootKeyHex();@@ -1326,6 +1326,12 @@13261326         const masterKey = unhexBytes(masterKeyHex);13271327         try {13281328           const identity = await buildLocalIdentity(masterKey);1329+          // If this device is Pro, advertise the signed license so the peer can1330+          // activate it after SAS confirmation — pairing carries Pro across.1331+          if (await isProUser()) {1332+            const license = await getLicenseKey();1333+            if (license) (identity as { license?: string }).license = license;1334+          }13291335           return { ok: true, data: identity };13301336         } finally {13311337           masterKey.fill(0);@@ -1333,9 +1339,8 @@13331339       }13341340 13351341       case "CHIRP_COMPLETE_PAIRING": {1342+        // Not Pro-gated — see CHIRP_GET_LOCAL_IDENTITY.13361343         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1337-        const proCheck = await requirePro("sync");1338-        if (!proCheck.allowed) return { ok: false, error: "pro_required" };13391344         const paired = (msg.payload as { paired?: unknown })?.paired;13401345         if (typeof paired !== "object" || paired === null) {13411346           return { ok: false, error: "invalid_payload" };@@ -1384,13 +1389,11 @@13841389       }13851390 13861391       // ── Desktop helper code pairing (peach-sync) ──────────────────────────1387-      // Start: gated exactly like CHIRP_GET_LOCAL_IDENTITY (unlocked + Pro).1388-      // The whole NK pairing runs in the background; the SAS and the terminal1389-      // result reach the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes.1392+      // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1393+      // pairing runs in the background; the SAS and the terminal result reach the1394+      // popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes.13901395       case "PAIR_WITH_DESKTOP": {13911396         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1392-        const proCheck = await requirePro("sync");1393-        if (!proCheck.allowed) return { ok: false, error: "pro_required" };13941397         const { code } = (msg.payload ?? {}) as { code?: string };13951398         if (typeof code !== "string" || !/\d/.test(code)) {13961399           return { ok: false, error: "invalid_payload" };

view file

src/popup/App.tsx+1 −0
@@ -1563,6 +1563,7 @@15631563                 <ChirpSyncView15641564                   isPro={proStatus()}15651565                   initialPair={syncPairIntent()}1566+                  onProChanged={() => { void refreshProStatus(); }}15661567                   onBack={() => { setSyncPairIntent(null); setView("settings"); }}15671568                   onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15681569                 />

view file

src/popup/chirp-pair.ts+30 −3
@@ -28,6 +28,7 @@2828   runPairingInitiator,2929   runPairingResponder,3030   type LocalIdentity,31+  type PairingHandshakeResult,3132 } from "../shared/chirp-handshake";3233 import {3334   DEVICE_TYPE_EXTENSION,@@ -70,6 +71,13 @@7071   /** Optional — cache the peer's IP for next session's fast path. */7172   cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;7273   /**74+   * Optional — activate a Pro license the peer sent in its identity frame.75+   * Called ONLY after the SAS is confirmed. The implementation verifies the76+   * signature and ignores it if this device is already Pro. This is how one77+   * Pro device grants Pro to the other when pairing (either direction).78+   */79+  activatePeerLicense?(license: string): Promise<void>;80+  /**7381    * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.7482    * Used to scan the LAN for the initiator's pairing-port WebSocket server.7583    */@@ -186,7 +194,7 @@186194   }187195 188196   const transport = await deps.openPairingTransport(peerEndpoint);189-  let result: { device: PairedDevice; sas: string };197+  let result: PairingHandshakeResult;190198   try {191199     result = await runPairingResponder(192200       attempt.ephemeralPrivateKey,@@ -206,6 +214,7 @@206214   // handshake (possibly a LAN/audio-range attacker) would be enrolled as a207215   // trusted sync peer (audit HIGH).208216   const paired = await gateOnSAS(deps, result);217+  await activatePeerLicenseIfAny(deps, result);209218 210219   // The connection's IP is cached in `chrome.storage.local` keyed by211220   // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3@@ -225,7 +234,7 @@225234 // MANDATORY (the chirp-handshake.ts security comment depends on this staying true).226235 export async function gateOnSAS(227236   deps: ChirpPairingDeps,228-  result: { device: PairedDevice; sas: string },237+  result: PairingHandshakeResult,229238 ): Promise<PairedDevice> {230239   let confirmed: boolean;231240   try {@@ -244,6 +253,23 @@244253   return result.device;245254 }246255 256+/**257+ * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the258+ * background to verify + activate. Best-effort: a bad/duplicate license never259+ * fails the pairing — the device link still succeeds.260+ */261+async function activatePeerLicenseIfAny(262+  deps: ChirpPairingDeps,263+  result: PairingHandshakeResult,264+): Promise<void> {265+  if (!result.peerLicense || !deps.activatePeerLicense) return;266+  try {267+    await deps.activatePeerLicense(result.peerLicense);268+  } catch {269+    // Invalid signature, already-Pro, or storage error — pairing stands.270+  }271+}272+247273 async function runInitiatorHandshake(248274   deps: ChirpPairingDeps,249275   remoteChirpPublicKey: Uint8Array,@@ -260,7 +286,7 @@260286     throw new ChirpPairingFailure("handshake_failed", (err as Error).message);261287   }262288 263-  let result: { device: PairedDevice; sas: string };289+  let result: PairingHandshakeResult;264290   try {265291     result = await runPairingInitiator(266292       remoteChirpPublicKey,@@ -277,6 +303,7 @@277303   // Same out-of-band SAS confirmation as the responder side — the initiator278304   // (mobile vault host) must not sync to an unverified peer either.279305   const paired = await gateOnSAS(deps, result);306+  await activatePeerLicenseIfAny(deps, result);280307 281308   await deps.completePairing(paired);282309   await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);

view file

src/popup/views/ChirpSync.tsx+15 −40
@@ -47,6 +47,8 @@4747    * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.4848    */4949   initialPair?: "chirp" | "desktop" | null;50+  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */51+  onProChanged?: () => void;5052 }5153 5254 // Which pairing path the user picked, passed in via `initialPair`. The birds@@ -259,6 +261,16 @@259261           setSasResolve(() => resolve);260262           setSasCode(sas);261263         }),264+      // Receive Pro from an already-Pro peer (signature-verified in the bg).265+      // Skip if we're already Pro so we keep our own license.266+      activatePeerLicense: async (license) => {267+        if (props.isPro) return;268+        const res = (await sendMessage({269+          type: "ACTIVATE_LICENSE",270+          payload: { key: license },271+        })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };272+        if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();273+      },262274       getLocalSubnet,263275       openPairingTransport: async (endpoint) => {264276         // Native WebSocket transport for the pairing-mode peer scan. The@@ -334,41 +346,6 @@334346 335347   // --- Sub-views --------------------------------------------------336348 337-  const proGateView = () => (338-    <div class="peach-chirp-settings peach-chirp-settings--gated">339-      <header class="peach-chirp-settings-header">340-        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>341-          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">342-            <path d="M11 4 L5 9 L11 14" />343-          </svg>344-        </button>345-        <h1 class="peach-chirp-settings-title">Sync</h1>346-      </header>347-      <div class="peach-chirp-pro-card">348-        <div class="peach-chirp-pro-stage" aria-hidden="true">349-          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"350-               stroke-linecap="round" stroke-linejoin="round">351-            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />352-            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />353-            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />354-          </svg>355-          <div class="peach-chirp-pro-bird-slot">356-            <MobileBird pose="idle" />357-          </div>358-        </div>359-        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>360-        <p class="peach-chirp-pro-body">361-          Pairs over an inaudible chirp. Syncs locally across your devices.362-          No codes, no servers, no cloud.363-        </p>364-        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>365-          Unlock with Peach Pro366-        </button>367-        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>368-      </div>369-    </div>370-  );371-372349   const backArrow = (373350     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">374351       <path d="M10 3 L5 8 L10 13" />@@ -553,9 +530,7 @@553530     </Show>554531   );555532 556-  return (557-    <Show when={props.isPro} fallback={proGateView()}>558-      {pairingView()}559-    </Show>560-  );533+  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can534+  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.535+  return pairingView();561536 };

view file

src/popup/views/Settings.tsx+7 −7
@@ -1531,12 +1531,9 @@15311531       <div class="peach-settings-body">15321532         <div class="peach-settings-group">15331533           <div class="peach-settings-row peach-settings-row--meta">1534-            <span>1535-              Status1536-              <Show when={!isPro()}><span class="peach-pro-badge">Pro</span></Show>1537-            </span>1534+            <span>Status</span>15381535             <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1539-              {isPro() ? "Ready" : "Requires Pro"}1536+              {isPro() ? "Pro" : "Free"}15401537               <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />15411538             </span>15421539           </div>@@ -1545,7 +1542,6 @@15451542           <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>15461543           <button15471544             class="peach-sync-pair-option"1548-            disabled={!isPro()}15491545             onClick={() => props.onOpenSyncPairing?.("chirp")}15501546           >15511547             <span class="peach-sync-pair-option-icon" aria-hidden="true">@@ -1562,7 +1558,6 @@15621558           </button>15631559           <button15641560             class="peach-sync-pair-option"1565-            disabled={!isPro()}15661561             onClick={() => props.onOpenSyncPairing?.("desktop")}15671562           >15681563             <span class="peach-sync-pair-option-icon" aria-hidden="true">@@ -1578,6 +1573,11 @@15781573             </span>15791574             <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>15801575           </button>1576+          <Show when={!isPro()}>1577+            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1578+              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1579+            </p>1580+          </Show>15811581 15821582           {/* Paired devices + manual sync — Settings is the hub. */}15831583           <Show when={pairedDevices().length > 0}>

view file

src/shared/chirp-handshake.test.ts+18 −0
@@ -62,6 +62,24 @@6262     expect(initResult.sas).toBe(respResult.sas);6363   });6464 65+  it("carries a Pro license from the device that has one to the peer", async () => {66+    const respEphemeral = generateKeyPair();67+    // The initiator (e.g. an existing Pro phone) advertises its signed license.68+    const initIdent = { ...identityFor("android-pro", "Pro Pixel", "android"), license: "PPK-AAA-BBB" };69+    const respIdent = identityFor("ext-free", "Free Firefox", "extension"); // no license70+71+    const [t1, t2] = memoryTransportPair();72+    const respPromise = runPairingResponder(respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2);73+    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);74+    const [respResult, initResult] = await Promise.all([respPromise, initPromise]);75+76+    // The free responder learns the peer's license to activate; the initiator77+    // sees none (we sent it nothing). License is never written onto the device.78+    expect(respResult.peerLicense).toBe("PPK-AAA-BBB");79+    expect(initResult.peerLicense).toBeUndefined();80+    expect("license" in respResult.device).toBe(false);81+  });82+6583   it("rejects a malformed identity frame", async () => {6684     const respEphemeral = generateKeyPair();6785     const respIdent = identityFor("ext-002", "Firefox", "extension");

view file

src/shared/chirp-handshake.ts+33 −2
@@ -38,6 +38,12 @@3838 export interface PairingHandshakeResult {3939   device: PairedDevice;4040   sas: string;41+  /**42+   * The peer's signed Pro license blob, if it advertised one. The caller43+   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro44+   * device can grant Pro to the other when pairing. Never persisted as-is.45+   */46+  peerLicense?: string;4147 }4248 4349 export interface LocalIdentity {@@ -56,6 +62,12 @@5662    * at sync-identity.ts:62 (PCH-02-006).5763    */5864   vaultId?: string;65+  /**66+   * This device's signed Pro license blob (`PPK-…`), included only when this67+   * device is Pro. The peer activates it (signature-verified) after the SAS is68+   * confirmed, so pairing carries Pro to a free device. Either direction.69+   */70+  license?: string;5971 }6072 6173 interface PairingFrame {@@ -69,6 +81,9 @@6981   // Optional: present only when the sender knows its vault id. peach-sync7082   // treats this as optional (pairing.zig:54); chirp mobile peers omit it.7183   vault_id?: string;84+  // Optional signed Pro license blob — additive; peers that don't understand85+  // it ignore it. Activated by the receiver only after SAS confirmation.86+  license?: string;7287 }7388 7489 function encodeFrame(frame: PairingFrame): Uint8Array {@@ -123,6 +138,7 @@123138     signing_public_key_hex: ourIdentity.signingPublicKeyHex,124139     ws_port: ourIdentity.wsPort,125140     vault_id: ourIdentity.vaultId,141+    license: ourIdentity.license,126142   });127143   await transport.send(session.encrypt(ourFrame));128144 @@ -131,7 +147,11 @@131147   const theirFrame = decodeFrame(theirPlain);132148   // The SAS is derived from the shared Noise handshake hash; the caller MUST133149   // have the user confirm it matches the peer's before persisting the device.134-  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };150+  return {151+    device: frameToPairedDevice(theirFrame),152+    sas: derivePairingSAS(session.handshakeHash),153+    peerLicense: peerLicenseOf(theirFrame),154+  };135155 }136156 137157 // ───────────────────────────────────────────────────────────────────────────@@ -227,13 +247,24 @@227247     signing_public_key_hex: ourIdentity.signingPublicKeyHex,228248     ws_port: ourIdentity.wsPort,229249     vault_id: ourIdentity.vaultId,250+    license: ourIdentity.license,230251   });231252   await transport.send(session.encrypt(ourFrame));232253 233254   // SAS derived from the shared handshake hash — caller must have the user234255   // confirm it matches the peer's code before persisting (defeats the235256   // initiator-impersonation / MITM attack the NK handshake alone allows).236-  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };257+  return {258+    device: frameToPairedDevice(theirFrame),259+    sas: derivePairingSAS(session.handshakeHash),260+    peerLicense: peerLicenseOf(theirFrame),261+  };262+}263+264+/** Pull a plausible license string off a peer frame (signature is verified265+ *  later, on activation). Anything non-string is ignored. */266+function peerLicenseOf(frame: PairingFrame): string | undefined {267+  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;237268 }238269 239270 function frameToPairedDevice(frame: PairingFrame): PairedDevice {

view file