The account key leaves /account, /pricing and /dashboard: a second token purpose, not a wider scope - #413
Merged
Conversation
… 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
force-pushed
the
fix/no-pgp-key-in-app-pages
branch
from
September 3, 2026 13:58
614be9c to
cc25131
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR #401 gave
/parashareapst_session token so the page never has to hold apgp_api-key. Three pages were left behind:/pricingfetched the key fromGET /api/user/account/keyand sent it asX-Api-Keyto start a checkout;/dashboarddid the same for the history and audit-export reads, and printed the masked key in its Operations card;/accountrendered 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.jsnow mints a token for a purpose, and the purpose picks the allowlist it is judged against.SCOPEis unchanged: the five ParaSend transfer routes.APP_SCOPEis new and holds three, each because one page needed exactly it:POST /v2/billing/checkout/pricingGET /v2/user/history/dashboard/v2/user/*(signing keys, TOTP) stays shutGET /v2/parasign/audit-export/dashboardThe two lists are disjoint. A token minted on
/parashareis403on all three app routes and a token minted on/pricingis403on 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/tokenasks forparasend, the newPOST /api/user/app/tokenasks forapp, and both ignore their request body. A purpose the relay does not know is refused at the mint with400 unknown_purposerather 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/pricingjs/app-session-token.jsmints an app token on the first click and sendsAuthorization: Bearer pst_. Nothing is minted on load/dashboard/api/user/dashboard/overviewstopped sendingkey_maskedwith itTests
tests/app-pages-no-api-key.test.mjs(new) drives real Chromium over the three pages and:/api/user/account/keyon load, or renders anything shaped like a key (textContent, notinnerText, because the/accountrow sits inside a closed<details>andinnerTextwould have made it pass for the wrong reason);pst_bearer and noX-Api-Keyheader.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.jspins that each route sends its own fixed word and that a body cannot change it.tests/site-claims.test.mjskeeps/privacypinned 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.jsturns the/accountcases red; the same for either of the other two pages./privacy,SECURITY.mdanddocs/api.mdare 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
/accountis 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.htmlis touched on two adjacent lines only (the new helper's script tag and the mandatory?vbump), because another change is editing text and buttons on that page.Test runs
node --test tests/*.test.mjs- 284 pass, 0 failnode --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- PASSscripts/check-cache-bust.sh,scripts/check-csp-inline.sh- OK