From 641f59674c7f4194fa084d857d3a916b2f627440 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sun, 2 Aug 2026 14:11:39 -0400 Subject: [PATCH] TigerPASS: require a signed VALID verdict to activate + lean key modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: activatePass previously refused only a definitive `lapsed` and accepted `unknown` (the ongoing nag-never-disable fail-open), so a well-formed but unprovable key could unlock the premium shelf. Activation is now strict — only a reached-home, signature-verified `valid` unlocks; `unknown`/`lapsed` are refused and the key is forgotten. (Fail-open still governs ONGOING operation: an already-active install keeps running through an authority outage — that's renewal, not first activation.) UX: the "Get TigerPASS" modal is now a lean key modal — a "Get My Key →" button that opens the seller's checkout + a paste-key field that activates. The sales pitch/pricing lives on the checkout page, not the modal. Adds regression tests (inject the authority transport): a trusted valid unlocks; a valid=false is refused; an UNSIGNED valid=true with a pinned key is refused (unknown) — the exact hole the always-say-yes stub exposed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 +++++ modules/system/languages/en/system.php | 1 + modules/system/services/Modules.php | 21 ++++--- .../system/views/scripts/modules/add.phtml | 63 ++++--------------- .../System/ModulesServiceMarketplaceTest.php | 50 +++++++++++++++ 5 files changed, 91 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33529c1..fc38c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Security +- **TigerPASS activation now requires a positive, signed `valid` verdict from the authority.** Previously + `activatePass` only refused a definitive `lapsed` and accepted `unknown` (the ongoing nag-never-disable + fail-open) — so a well-formed but unprovable key (or an authority that couldn't cryptographically prove + entitlement) could unlock the premium shelf. Activation is now strict: only a reached-home, + signature-verified `valid` unlocks; `unknown`/`lapsed` are refused and the key is forgotten. (The + fail-open rule still applies to *ongoing* operation — an already-active install keeps running through an + authority outage.) + +### Changed +- **The "Get TigerPASS" modal is now a lean key modal.** Replaced the two-phase sales pitch (plan cards, + feature shelf) with a single modal: a **Get My Key →** button that opens the seller's checkout, and a + paste-your-key field that activates. The pricing + value prop live on the checkout page, not the modal. + ## [0.47.0-beta] — 2026-08-02 ### Added diff --git a/modules/system/languages/en/system.php b/modules/system/languages/en/system.php index fedf82a..1e90a2b 100644 --- a/modules/system/languages/en/system.php +++ b/modules/system/languages/en/system.php @@ -30,6 +30,7 @@ 'system.pass.invalid_format' => 'That doesn\'t look like a TigerPASS key (it\'s a code like 019f88b1-7ce7-7467-95b3-db7a7433342c).', 'system.pass.not_configured' => 'TigerPASS isn\'t configured on this install yet.', 'system.pass.lapsed' => 'That subscription has lapsed — renew it at webtigers.com, then try again.', + 'system.pass.unverified' => 'We couldn\'t verify that key with WebTigers. Check that you pasted it correctly, or try again in a moment.', 'system.pass.nag_snoozed' => 'TigerPASS reminder hidden for 30 days.', 'system.pass.nag_updated' => 'Preference saved.', 'system.source.connected' => 'Marketplace connected.', diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index b316acf..19b688a 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -458,10 +458,14 @@ protected static function _slugId(string $id): string /** * Activate a TigerPASS subscription key on this install. Validates the key shape, remembers it under - * the reserved pass slug, and verifies it against the pass authority. NAG-NEVER-DISABLE: activation is - * refused ONLY on a definitive, reached-home `lapsed` verdict; an unreachable authority yields - * `unknown` and is accepted (assume-current — an authority outage must never block a paying customer). - * The heavy commerce (buy/renew) lives on webtigers.com; this endpoint only accepts the resulting key. + * the reserved pass slug, and verifies it against the pass authority. Activation requires a POSITIVE, + * SIGNED `valid` verdict — an unprovable key (a definitive `lapsed`, OR an `unknown` from an + * unreachable/untrusted authority — e.g. an unsigned reply, or one whose signature doesn't match the + * pinned public key) is refused and the key forgotten, so a random UUID can never unlock the premium + * shelf. This is deliberately STRICTER than the ongoing nag-never-disable gate: that fail-open keeps an + * ALREADY-active install running through an authority outage (renewal), but you can't ACTIVATE off a + * key we couldn't prove in the first place. The heavy commerce (buy/renew) lives on webtigers.com; this + * endpoint only accepts the resulting key. * * @param array $params the /api payload (expects `key`) * @return void @@ -488,9 +492,12 @@ public function activatePass(array $params): void 'public_key' => self::_passPublicKey(), ]); $verdict = Tiger_License_Checker::verify(self::PASS_SLUG); // the one deliberate network check - if ($verdict['state'] === Tiger_License_Checker::LAPSED) { - Tiger_License_Checker::forget(self::PASS_SLUG); // don't keep a proven-lapsed key - $this->_error('system.pass.lapsed'); return; + if ($verdict['state'] !== Tiger_License_Checker::VALID) { + // Only a reached-home, signature-verified `valid` unlocks. `lapsed` = told no; anything else + // (`unknown`) = we couldn't prove it — either way, forget the key and never unlock. + Tiger_License_Checker::forget(self::PASS_SLUG); + $this->_error($verdict['state'] === Tiger_License_Checker::LAPSED ? 'system.pass.lapsed' : 'system.pass.unverified'); + return; } $this->_success(['pass' => self::_passState()], 'system.pass.activated'); } catch (Throwable $e) { diff --git a/modules/system/views/scripts/modules/add.phtml b/modules/system/views/scripts/modules/add.phtml index dad8dd6..c9575ae 100644 --- a/modules/system/views/scripts/modules/add.phtml +++ b/modules/system/views/scripts/modules/add.phtml @@ -546,70 +546,31 @@ document.addEventListener('DOMContentLoaded', function () { var passModal = new bootstrap.Modal(passModalEl); var passContent = document.getElementById('pass-content'); - function passShelf(){ - // The PASS-covered modules currently in the catalog — the "all this for $5?" shelf. - var names = allResults.filter(function(m){ return (m.availability||'')==='pass'; }) - .map(function(m){ return m.module||m.slug; }); - // De-dup + cap the visible list; always end with the "future" line. - var seen={}, uniq=[]; names.forEach(function(n){ if(n && !seen[n]){ seen[n]=1; uniq.push(n); } }); - var rows = uniq.slice(0,7).map(function(n){ return '
'+esc(n)+'
'; }); - rows.push('
Every future premium module
'); - return '
'+rows.join('')+'
'; - } - - function openPass(refName){ - renderPassPhase1(refName); + function openPass(){ + renderPassModal(); passModal.show(); } - function renderPassPhase1(refName){ - var refBar = refName ? '
You clicked '+esc(refName)+' — it\'s one of these. TigerPASS unlocks the whole shelf, not just this one.
' : ''; + // A lean key modal: a "Get My Key" button (opens the seller's Shop checkout in a new tab — the sales + // pitch + pricing live THERE, not here) and a paste-key field that activates on a well-formed key. + function renderPassModal(){ passContent.innerHTML = ''+ - ''; - passContent.querySelectorAll('[data-plan]').forEach(function(b){ - b.addEventListener('click', function(){ renderPassPhase2(b.getAttribute('data-plan')); }); - }); - } - - function renderPassPhase2(plan){ - var priceLine = plan==='yearly' ? '$50/year' : '$5/month'; - // Open the seller's hosted checkout in a new tab (popup window, never an iframe — 3DS + hosted - // checkout can't be framed). The modal stays here as the waiting/paste-key state. - try { window.open(PASS_CHECKOUT + (PASS_CHECKOUT.indexOf('?')<0?'?':'&') + 'plan=' + encodeURIComponent(plan), '_blank', 'noopener'); } catch(e){} - passContent.innerHTML = - '