Skip to content

The account key leaves /account, /pricing and /dashboard: a second token purpose, not a wider scope - #413

Merged
Apolloccrypt merged 1 commit into
mainfrom
fix/no-pgp-key-in-app-pages
Sep 3, 2026
Merged

Apolloccrypt merged 1 commit into
mainfrom
fix/no-pgp-key-in-app-pages

Conversation

@Apolloccrypt

Copy link
Copy Markdown
Owner

PR #401 gave /parashare a pst_ session token so the page never has to hold a pgp_ api-key. Three pages were left behind:

  • /pricing fetched the key from GET /api/user/account/key and sent it as X-Api-Key to start a checkout;
  • /dashboard did the same for the history and audit-export reads, and printed the masked key in its Operations card;
  • /account rendered the masked key on load, whether or not anybody asked to see it.

An api-key has no expiry and no scope, so each of those visits put a full data-plane credential, or the shape of one, into a tab opened to read a price or check a quota.

A second purpose, not a wider scope

relay/lib/session-token.js now mints a token for a purpose, and the purpose picks the allowlist it is judged against.

SCOPE is unchanged: the five ParaSend transfer routes. APP_SCOPE is new and holds three, each because one page needed exactly it:

Route Page Why
POST /v2/billing/checkout /pricing pressing a price button creates the Mollie payment. It moves no money itself and reveals nothing about the account
GET /v2/user/history /dashboard a read-only projection over the account's own audit chain: identifiers, status and timing, never a payload or a key. Named on its own path, never as a prefix, so the rest of /v2/user/* (signing keys, TOTP) stays shut
GET /v2/parasign/audit-export /dashboard read-only over the same chain, tier-gated at Business+ by the route itself

The two lists are disjoint. A token minted on /parashare is 403 on all three app routes and a token minted on /pricing is 403 on all five transfer routes. Merging them into one flat allowlist would have widened the ParaSend token by three routes to give three other pages a credential they needed, which is how a narrow credential quietly becomes an api-key again.

The purpose is fixed by the admin route, never named by the caller: POST /api/user/parasend/token asks for parasend, the new POST /api/user/app/token asks for app, and both ignore their request body. A purpose the relay does not know is refused at the mint with 400 unknown_purpose rather than folded onto a default, and a stored record carrying an unknown purpose authenticates nobody. A record with no purpose field predates this change and is a ParaSend token, so live tokens survive the deploy.

What the pages do now

Page Credential
/account the reveal route, and only when the existing "Advanced account key" fold is opened. It shows masked there; Show and Copy reuse that one fetch. Nothing is fetched or rendered on load
/pricing js/app-session-token.js mints an app token on the first click and sends Authorization: Bearer pst_. Nothing is minted on load
/dashboard the same for the history view and the audit export. The Operations card no longer prints a key in any form, and /api/user/dashboard/overview stopped sending key_masked with it

Tests

tests/app-pages-no-api-key.test.mjs (new) drives real Chromium over the three pages and:

  • fails if any of them asks for /api/user/account/key on load, or renders anything shaped like a key (textContent, not innerText, because the /account row sits inside a closed <details> and innerText would have made it pass for the wrong reason);
  • pins that opening the fold is what asks, and asks exactly once, and that Show does not ask again;
  • presses a real price button and checks the checkout call carries a pst_ bearer and no X-Api-Key header.

The relay unit and route suites gained both purposes from both ends, including the wall between them and the fail-closed behaviour of an unknown purpose. admin/test/parasend-token.test.js pins that each route sends its own fixed word and that a body cannot change it. tests/site-claims.test.mjs keeps /privacy pinned to the code in both directions: the holder list is one page now, and the ex-holders are asserted to stay moved.

Verified by sabotage: putting the key fetch back at the top of account.inline1.js turns the /account cases red; the same for either of the other two pages.

/privacy, SECURITY.md and docs/api.md are rewritten with the code rather than after it.

What is left, stated plainly

The reveal route still exists and still answers a signed-in browser with the raw key, because /account is the page whose job is to show it to you and a self-hoster needs it. So a script with a session cookie can still ask for it directly. What changed is that it no longer finds one lying in a variable on two pages nobody opened for that reason.

Not done here

Not deployed, not merged. frontend/pricing.html is touched on two adjacent lines only (the new helper's script tag and the mandatory ?v bump), because another change is editing text and buttons on that page.

Test runs

  • node --test tests/*.test.mjs - 284 pass, 0 fail
  • node --test relay/test/session-token.test.js relay/test/route-session-token.test.js relay/test/route-auth-gate.test.js relay/test/v1-bearer-gate.test.js relay/test/quota-upgrade-render.test.js admin/test/parasend-token.test.js - 88 pass, 0 fail (redis-backed, not skipped)
  • bash tests/static-sanity.sh - PASS
  • scripts/check-cache-bust.sh, scripts/check-csp-inline.sh - OK

… browser

PR #401 gave /parashare a pst_ session token so the page never has to hold a
pgp_ api-key. Three pages were left behind: /pricing fetched the key from
GET /api/user/account/key and sent it as X-Api-Key to start a checkout,
/dashboard did the same for the history and audit-export reads, and /account
rendered the masked key on load whether or not anybody asked to see it. An
api-key has no expiry and no scope, so each of those visits put a full
data-plane credential, or the shape of one, into a tab opened to read a price
or check a quota.

A SECOND PURPOSE, NOT A WIDER SCOPE. relay/lib/session-token.js now mints a
token FOR a purpose, and the purpose picks the allowlist it is judged against.
SCOPE is unchanged: the five ParaSend transfer routes. APP_SCOPE is new and
holds three, each because one page needed exactly it:

  POST /v2/billing/checkout       /pricing, pressing a price button creates the
                                  Mollie payment; it moves no money itself and
                                  reveals nothing about the account.
  GET  /v2/user/history           /dashboard, a read-only projection over the
                                  account's own audit chain. Named on its own
                                  path, never as a prefix, so the rest of
                                  /v2/user/* (signing keys, TOTP) stays shut.
  GET  /v2/parasign/audit-export  /dashboard, read-only over the same chain and
                                  tier-gated at Business+ by the route itself.

The two lists are disjoint. A token minted on /parashare is 403 on all three
app routes and a token minted on /pricing is 403 on all five transfer routes.
Merging them into one flat allowlist would have widened the ParaSend token by
three routes to give three other pages a credential they needed, which is how a
narrow credential quietly becomes an api-key again.

The purpose is fixed by the admin route, never named by the caller:
POST /api/user/parasend/token asks for parasend, the new
POST /api/user/app/token asks for app, and both ignore their request body. A
purpose the relay does not know is refused at the mint with 400 unknown_purpose
rather than folded onto a default, and a stored record carrying an unknown
purpose authenticates nobody. A record with no purpose field predates this
change and is a ParaSend token, so live tokens survive the deploy.

WHAT THE PAGES DO NOW.

  /account    fetches the key only when the existing "Advanced account key"
              fold is opened, and shows it masked there; Show and Copy reuse
              what that fetch returned. Nothing is fetched or rendered on load.
  /pricing    js/app-session-token.js mints an app token on the first click and
              sends Authorization: Bearer pst_. Nothing is minted on load.
  /dashboard  the same for the history view and the audit export, and the
              Operations card no longer prints a key in any form. The overview
              endpoint stopped sending key_masked with it.

TESTS. tests/app-pages-no-api-key.test.mjs drives real Chromium over the three
pages and fails if any of them asks for the key on load or renders anything
shaped like one, pins that opening the fold is what asks and asks exactly once,
and presses a real price button to check the checkout call carries a pst_
bearer and no X-Api-Key header. The relay unit and route suites gained the two
purposes from both ends, including the wall between them. site-claims keeps
/privacy pinned to the code in both directions: the holder list is one page now,
and the ex-holders are asserted to stay moved.

/privacy, SECURITY.md and docs/api.md are rewritten with the code rather than
after it. The reveal route still exists and still answers a signed-in browser,
because /account is the page whose job is to show you your key and a self-hoster
needs it; what changed is that the key is no longer lying in a variable on two
pages nobody opened for that reason.
@Apolloccrypt
Apolloccrypt force-pushed the fix/no-pgp-key-in-app-pages branch from 614be9c to cc25131 Compare September 3, 2026 13:58
@Apolloccrypt
Apolloccrypt merged commit 7380ea0 into main Sep 3, 2026
15 checks passed
@Apolloccrypt
Apolloccrypt deleted the fix/no-pgp-key-in-app-pages branch September 5, 2026 18:57
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