Skip to content

fix(licensing): wave 2 — session-lookup key leak, expiry enforcement, key-in-logs, +6 - #495

Merged
runyourempire merged 2 commits into
mainfrom
worktree-fix+licensing-hardening-2
Aug 19, 2026
Merged

fix(licensing): wave 2 — session-lookup key leak, expiry enforcement, key-in-logs, +6#495
runyourempire merged 2 commits into
mainfrom
worktree-fix+licensing-hardening-2

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

What this is

Wave 2 of the licensing-pipeline hardening audit (Wave 1 = #494, merged+deployed). A deeper adversarial sweep — three parallel hunters (site / app / frontend) + direct analysis — found several more issues across the whole pipeline. Every confirmed finding here is fixed and test-covered; two items requiring an app release or a product decision are documented for the operator (not code-fixable this pass).

Fixed & verified

Server (Cloudflare Pages)

  • H1 (HIGH) — session-lookup returned ANY customer's key with no payment check. GET ?session_id= trusted a session id minted at creation (pre-payment) and resolved the customer by buyer-typed email, so starting a checkout with a victim's email and abandoning it (no payment) let an attacker read that victim's offline-verifiable key. Now: gate on sessionProvesPurchase (status complete / paid), bind to session.customer (never an email list), and time-box the session id to 24h (sessionWithinWindow). Pure predicates unit-tested.
  • M4 (MED) — no charge.dispute.closed handler. A won dispute left a paying monthly subscriber terminal forever (the isTerminal guard then blocks every renewal). handleDisputeClosed restores active on status === 'won', only from the exact chargeback state.
  • M3 (MED) — notify.js unauthenticated + unmetered → unbounded Stripe customer creation. LICENSE_KV per-IP limiter (20/hr), fail-open, separate key namespace.
  • CSP + no-referrer (L6/L7) — strict CSP (default-src 'none', connect-src 'self') on the two key-bearing pages so an injected script can't exfiltrate the key, and the session-id credential never leaks in a Referer.
  • checkout.js writes signal_tier (was legacy streets_tier) — hygiene.

App (Rust)

  • Key-in-production-logs (MED) — the full fourda://activate?key=<SECRET> was logged at info/warn across 9 sites and written to the security_events DB, so the bearer key landed in data_dir/logs/*.log in cleartext (support bundles, cloud-synced folders). New utils::redact_deep_link (byte-safe vs hostile UTF-8) masks the key= value everywhere.
  • Expiry never enforced + settings.json tamper (MED)has_license_key_available returned true for any non-empty key, so an expired 4DA- key kept granting Signal forever (a cancelled monthly subscriber never downgraded) and pasting tier:"signal"+garbage unlocked Signal. New key_is_usable verifies signature and expiry. Makes Terms §4.5 true for subscriptions for the first time.
  • Recovery auto-activated an unverified key (F3) — removed (the live server never returns a key here; it was dead + dangerous if 4da.ai were repointed).
  • Forgeable Keygen cache (F2) — the doc comments claimed it was cryptographic; it isn't (bare SHA-256). Corrected the claims + added a named adversarial test pinning the real honesty-box behaviour.
  • Trial 45-vs-14 liestart_trial now reports the real value from get_trial_status.
  • Terms §5.3 reworded from the impossible "the licence is deactivated" to the enforceable "the licence terminates; continued use is not permitted."

Verified

  • Rust: 25 license tests + 22 url tests pass (6 new), cargo fmt + clippy --lib clean, no size breaches.
  • Site: 64 lib tests pass (session-predicate + revoked-matrix added).

Documented for the operator (not code-fixable this pass)

  • Deep-link activation consent (MED) — a website can silently swap a paying user's licence for the attacker's own valid key (DoS/griefing). The fix is a UI consent flow, and the project's verification rule requires UI/entitlement changes to be tested against the running app; deferred as a focused, live-verified follow-up with the design ready (email-match refusal + confirmation modal). This is the top open security item.
  • Lease-client arc — real lifetime-refund revocation + killing the monthly re-paste. Needs an app release + a NETWORK.md/ADR decision.
  • Signal-feed "isPro" gate — per tier-truth fix(tier-truth): sell only reachable Signal features — Blind Spots + Knowledge Gaps #473 that feed is free-floor, so this is a vestigial sales-tease, NOT a data leak (all actually-sold features enforce server-side). Product call, not a patch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp

runyourempire and others added 2 commits August 19, 2026 20:28
… and 6 more

Deeper adversarial sweep (3 parallel hunters + analysis) of the whole licensing
pipeline after wave 1 (#494). Findings, each fixed and tested:

SERVER (Cloudflare Pages)
- H1 (HIGH) session-lookup returned ANY customer's key with no payment check.
  GET ?session_id= trusted a session id minted at CREATION (pre-payment) and
  resolved the customer by buyer-typed email — so starting a checkout with a
  victim's email, entering it, and abandoning it (no payment) let an attacker
  read that victim's offline-verifiable key. Now: gate on sessionProvesPurchase
  (status complete / paid), bind to session.customer (never an email list), and
  time-box the session id to 24h (sessionWithinWindow). Pure predicates unit-tested.
- M4 (MED) no charge.dispute.closed handler → a WON dispute left a paying monthly
  subscriber terminal forever (isTerminal then blocks every renewal). handleDispute
  Closed restores 'active' on status 'won', only from the exact 'chargeback' state.
- M3 (MED) notify.js was unauthenticated + unmetered → unbounded Stripe customer
  creation. LICENSE_KV per-IP limiter (20/hr), fail-open, separate key namespace.
- L6 strict CSP (default-src 'none', connect-src 'self') + L7 no-referrer on the
  two key-bearing pages (/activate, /signal/success) so an injected script could
  never exfiltrate the key and the session-id credential never leaks in Referer.
- checkout.js writes signal_tier (was legacy streets_tier) — hygiene.

APP (Rust)
- Expiry was NEVER enforced + settings.json tamper (MED): has_license_key_available
  returned true for ANY non-empty license_key, so an EXPIRED 4DA- key kept granting
  Signal forever (the only auto-downgrade fires on an ABSENT key; validate_license
  is never auto-called) and pasting tier:"signal"+garbage unlocked Signal. New
  key_is_usable verifies signature+expiry in the fast path and keychain branch.
  Makes Terms 4.5 (features unavailable on lapse) true for subscriptions for real.
- Recovery command auto-activated a server-returned key with ZERO verification
  (F3) — a user repointing 4da.ai at their own server could grant any tier. The
  live server never returns a key here (always 202); dead+dangerous arm removed.
- Keygen validation cache is offline-forgeable (F2, honesty-box): key_hash is a
  bare SHA-256, and the doc comments CLAIMED it was cryptographic. Corrected the
  false claims; added a named adversarial test pinning the real behaviour.
- Trial 45-vs-14 lie: start_trial reported 45 days; gating expires at 14. Now
  reports the real value from get_trial_status.

Verified: 25 Rust license tests pass (3 new), cargo fmt + clippy --lib clean, no
size breaches; 64 site lib tests pass (session-predicate + revoked-matrix added).
Terms 5.3 reworded to the honest, enforceable statement (offline lifetime keys
cannot be technically deactivated today — the lease arc that would change that is
the open operator decision in the plan).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp
…hunter #2)

The deep-link handlers logged the full fourda://activate?key=<SECRET> at info/warn
across 9 sites in app_setup.rs and wrote it to the security_events DB table. The
production build attaches a daily-rotated file appender at info level, so the
self-signed 4DA- key — an offline-verifiable BEARER credential for the Signal
tier — was landing in data_dir/logs/*.log and the DB in cleartext, where it rides
along in support-bundle zips, cloud-synced profile folders and screen-shares.

New utils::redact_deep_link masks the value of any `key=` param (query OR
fragment) with <redacted>, keeping host/path/other params for forensics. It is
byte-level so an unvalidated (rejected-path) URL carrying hostile multi-byte
UTF-8 can never panic on a non-char-boundary slice. Applied at all 6 log sites +
3 security_events writes. Unit-tested (query, fragment, other-params, the
"monkey" false-positive, no-key passthrough).

Verified: 22 utils::url tests pass (3 new), cargo fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp
@runyourempire
runyourempire enabled auto-merge (squash) August 19, 2026 16:53
@runyourempire
runyourempire merged commit 6dfb911 into main Aug 19, 2026
20 of 26 checks passed
@runyourempire
runyourempire deleted the worktree-fix+licensing-hardening-2 branch August 19, 2026 17:55
runyourempire added a commit that referenced this pull request Aug 20, 2026
…ired STREETS brand (#497)

## Why

`/api/streets/activate` is the production Signal licence endpoint
wearing the name of a product retired in June 2026. Every reader — human
and agent — kept mistaking the payment path for curriculum leftovers;
the operator was one dashboard click from deleting the live Stripe event
destination because its URL reads as STREETS junk. The label is the
hazard. This PR removes it.

## What

- **Canonical route is now `/api/license/activate`** (beside the
existing `/api/license/refresh`). `functions/api/streets/activate.js`
becomes a 3-line re-export shim: pre-rename desktop builds have the
recovery URL baked into the binary, and the Stripe event destination
points at the old URL until its one-time dashboard edit. Both routes
verified present in the compiled Worker (`wrangler pages functions
build`).
- **Desktop app recovery URL** (`settings_commands_license.rs`)
repointed to the new path.
- **`setup-signal.mjs` now checks all SIX handled events** —
`charge.refunded`, `charge.dispute.created`, `charge.dispute.closed`
were missing from its checklist while the deployed handler (#494/#495)
handles them. It also finds the destination on either path.
- **`streets.4da.ai` dropped from CORS** — NXDOMAIN (verified against
1.1.1.1); nothing has served from it since STREETS retired.
- **Latent e2e bug fixed in passing:** the invalid-tier test POSTed to
`/api/streets/checkout`, a route that has never existed on Cloudflare
(checkout is `/api/signal/checkout`) — it was asserting against the
static 404 page, not the handler's 400 branch.
- `'No STREETS license found'` 404 now matches the endpoint's other 404
bodies.
- Docs repointed: NETWORK.md (with a legacy-path note for pre-rename
builds), DECISIONS.md AD-028 code pointer, validate.yml comment,
generate-license.mjs, verify-ed25519-equivalence.mjs.

## What is deliberately NOT touched

- **Stripe customer metadata** — the `streets_*` → `signal_*` namespace
migration already shipped with a legacy read-fallback in
`lib/entitlement.js`; no customer record is affected by this PR.
- **`lib/recovery-email.js` historical comment** — file is claimed by a
peer lane (`fix-open-code-17-21`); the comment describes the 2026-08-14
era when the old path was canonical, so it stays accurate as written.
- **App-internal `streets_engine` / `streets_localization` /
`docs/streets`** — retired-curriculum machinery unrelated to payments;
renaming the DB column is a schema migration and a separate decision.

## Operator follow-up (after this deploys to 4da.ai)

One edit on the existing Stripe event destination (Workbench → Webhooks
→ ⋯ → Update details): set URL to `https://4da.ai/api/license/activate`
and select all six events (`checkout.session.completed`, `invoice.paid`,
`customer.subscription.deleted`, `charge.refunded`,
`charge.dispute.created`, `charge.dispute.closed`). Same signing secret
— no env change, no gap. This simultaneously closes the open P0
(destination only subscribes to 3 of 6 events). If a WAF rate-limit rule
was ever created for `/api/streets/activate`, add the new path to it.

## Verification

- `wrangler pages functions build` compiles; both `api/license/activate`
and `api/streets/activate` present in the bundle
- 64/64 site lib tests, 1242/1242 frontend tests, cargo fmt + clippy
clean (pre-push gate)
- `check-file-sizes.cjs` clean (warnings only, none from this diff)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01U2x3UAWbQvqU2DiQL4pSK9

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant