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
↓ peach-browser-628eb270.tar.gzatlas at this save
files changed10 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 −2manifest.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": {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",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",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" };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 />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);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 };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}>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");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 {