The ParaSend account key never reaches the browser: a scoped session token - #401
Merged
Conversation
…wser relay/lib/session-token.js mints pst_ tokens: fifteen minutes, held in the shared Redis so all five sectors honour the same one, resolving to the api-key they were minted for. Inside an allowlist of five routes (check-key, ws-ticket, pubkey publish and read, inbound) the token authenticates as that key, so quota, audit, device queues and tier limits all resolve against the owner account. Every other path is 403 above the route handlers, including /v2/user/*, /v2/outbound, /v2/audit, /v2/admin/* and a second mint. POST /v2/session-token needs both X-Internal-Auth and a live X-Api-Key, so the admin plane is the only caller and a browser can never name another account. Revoking a key sweeps its tokens from the store, and a token whose owner key is inactive grants no principal even when that sweep did not run. No store means 503 with Retry-After, never 401. relay/test/session-token.test.js covers the decisions without redis; relay/test/route-session-token.test.js drives the same rules over HTTP against a booted relay, including that an upload made with a token counts on the owner quota and lands in the owner audit chain.
…sion token /parashare asked GET /api/user/account/key and held a full data-plane credential, with no expiry and no scope, for the life of the tab. It now asks POST /api/user/parasend/token and holds a pst_ token instead: fifteen minutes, five routes, and no key at all in the page. The admin route sits behind authUser, reads the account from the session through proxyApiKey and returns strictly the token and its lifetime. It never falls back to the key when the mint fails, because that would put the credential back in the browser on exactly the days something is already wrong. The reveal route stays for the account page and the self-host flow. parashare.page.js gets one place that decides which header a relay call carries, so the five call sites cannot drift: a Bearer on the hosted path, X-Api-Key on the manual self-host path, and one refresh with one retry when a token runs out mid-session. Typing a key by hand drops the token, so the page never holds two credentials. /privacy now states what is in the browser and what is not, including the self-host case where a key really is; site-claims rows 28 and 36 hold both the sentence and the code to it. SECURITY.md records the ceiling that remains: a script on the site can still act as the user for fifteen minutes, and the account/key reveal route is still reachable from a signed-in browser.
Apolloccrypt
force-pushed
the
feat/parasend-session-token
branch
from
September 3, 2026 10:09
6b90243 to
417a20f
Compare
/v1 knows psk_ developer keys; /v2 now also reads an Authorization Bearer for pst_ ParaSend session tokens. Neither may be readable as the other, and the direction that would hurt is a browser token becoming a developer credential on an open API, so the boundary is asserted in both suites rather than left to the prefixes. route-auth-gate also pins what the new credential does to the gate on a relay with no store: a pst_-shaped Bearer is 503, and every other Bearer, including the admin token and an api-key in the wrong header, is the same 401 as before.
relayFetch could ask for a replacement token twice on the same call: once proactively inside the refresh margin, and again on the 401 that a failed refresh made certain. nginx allows a burst of five on /api/user/, and a page that spends two attempts per relay call turns one bad minute into a rate limit of its own. A refresh that already failed on this call now skips the retry, because a 401 after it is not something a second attempt will fix.
The first version of this claim said the account key was not in the browser at all. That was true of /parashare and false of three other pages: /account reveals it on purpose, and /pricing and /dashboard still authenticate to the relay with the key itself. A privacy page that describes a credential and gets it wrong in that direction is the same failure as the paramant_api_key line it replaced, one draft later. /privacy now names all four and says what each does. site-claims row 36 holds the list from both ends: a page that stops fetching the key while still listed fails, and a fourth page that starts fetching it without being listed fails too. SECURITY.md carries the same three files in its open-findings table, with what closing them would take.
Apolloccrypt
force-pushed
the
feat/parasend-session-token
branch
from
September 3, 2026 10:20
daa4868 to
e112535
Compare
Apolloccrypt
marked this pull request as ready for review
September 3, 2026 10:31
…mark the audit
Three things from the security review of the branch, plus the ceiling it asked
for or asked to be noted.
The stored record carried the api-key in the clear. The key NAMES were already
hashed, because SCAN output, keyspace listings and the slowlog all show names,
but the value was not, so an RDB snapshot, a replica, a backup on a laptop or a
MONITOR session held live pgp_ credentials for every account that had sent a
file in the last fifteen minutes. The record is now {kh, exp}, and the relay
turns the hash back into a key through an index over the api-key table it
already has in memory. Nothing derives a key from a hash. The index is built on
load and on reload-users, and heals itself on a miss, because keys are also
created at run time through routes that go near neither.
The expiry was optional, guarded with a typeof, so a record that lost the field
fell back to whatever TTL redis had on it and one written without it never
expired on the wall clock at all. It is required now: missing or not a number is
a refusal.
A transfer made with a token carries via: "pst" in the owner's audit chain. Not
a second identity, the chain is still keyed on the owner's api-key; without it
an owner cannot tell a transfer made from a browser session apart from one made
with the key itself.
And a ceiling of 20 live tokens per account, answered 429 with a Retry-After.
The sweep index is pruned of names redis has already expired before a refusal is
made, so nobody is refused because of tokens that are gone, and the prune runs
only when the cheap count says the cap is in reach.
Apolloccrypt
added a commit
that referenced
this pull request
Sep 3, 2026
… 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.
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.
The security review of #397 accepted that /parashare had stopped keeping a key
in
localStorageand then said the harder thing: the key should not be in thebrowser at all. It named the answer, and this is it.
/parashareused to fetch the account API key and hold it in a variable for thelife of the tab. That key has no expiry and no scope: anything that got to run
script on the page could read it and keep it, and use it for uploads, downloads,
the audit chain, signing-key enrolment and ParaSign envelopes, until the owner
noticed and rotated. The page now holds a
pst_session token instead. Fifteenminutes. Five routes. No key.
Relay
relay/lib/session-token.jsmints the tokens into the shared Redis rather than aMap, because the five sectors are separate processes behind one store and the
page discovers its sector at run time.
Inside an allowlist of five routes the token authenticates as the api-key it
was minted for:
/v2/check-key,POST /v2/ws-ticket,POST /v2/pubkey,GET /v2/pubkey/:device,POST /v2/inbound. Quota, the audit chain, devicequeues and the per-tier limits therefore resolve against the owner account,
byte-identical to a request that carried the key. Every other path is
403 session_token_out_of_scope, decided above every route handler so thesixty-ninth route cannot forget to ask:
/v2/user/*,/v2/outbound,/v2/audit,/v2/admin/*, the ParaSign routes, and a second mint.POST /v2/session-tokenneedsX-Internal-Authand a liveX-Api-Key, sothe admin plane is the only caller and a browser can never name an account other
than the one it is signed in as. An unconfigured internal token means closed.
Revoking a key sweeps its live tokens out of the store, and a token whose owner
key is inactive grants no principal even when that sweep never ran. Two locks,
because a revocation that rests on a best-effort write is not a revocation.
Store unreachable is
503withRetry-After, never401: a 401 in an outagetells a sender holding a good token that it is bad, and sends them to sign in
over a store that cannot do that either.
Admin
POST /api/user/parasend/token, behindauthUser. It takes no body: the accountcomes from the session through
proxyApiKey, so a browser cannot name anotherone. It returns the token and its lifetime, and nothing else. It does not fall
back to the key when the mint fails, because that would put the credential back
in the browser on exactly the days something is already wrong.
GET /api/user/account/keystays. It is not what/parashareuses any more, andthe account page and a self-hoster still need it.
Frontend
One function decides which header a relay call carries, so the five call sites
cannot drift: a Bearer on the hosted path,
X-Api-Keyon the manual self-hostpath.
X-Api-Keynow appears exactly once inparashare.page.js, and a testholds it there.
A token can die during a long send, so there are two guards: a refresh a minute
before the expiry the page knows about, and one 401 from the relay answered with
one mint and one retry. Never a loop. Typing a key by hand drops the token, so
the page never holds two credentials.
Hardening from the security review (commit
499be50d)clear. Key names were already hashed (SCAN, keyspace listings and the
slowlog all show names); the value was not, so an RDB snapshot, a replica, a
backup or a
MONITORsession held livepgp_credentials for every accountthat had sent a file in the last fifteen minutes. The record is now
{kh, exp}and the relay resolves the hash through an index over the api-keytable it already holds in memory. Built on load and on
/v2/reload-users, andself-healing on a miss, because keys are also created at run time by routes
that touch neither.
typeof, so a recordthat lost the field fell back to whatever TTL redis had, and one written
without it never expired on the wall clock at all.
via: "pst"on the audit entry when the action ran through a token. A noteon the credential, not a second identity: the chain stays keyed on the owner's
api-key.
429withRetry-After. Thesweep index is pruned of names redis already expired before a refusal is made,
and the prune runs only when the cheap
sCardsays the cap is in reach.Sabotages for each: put the key back in the record, restore the
typeofguardon
exp, drop the audit field, set the cap toInfinity, make the prune removelive tokens, and stop the hash index healing on a miss. All six go red by name.
The route suite also reads the real store the way a leak would (
SCANover thewhole prefix plus the record bodies) and asserts nothing shaped like an api-key
is in it.
Suites are now 26 (unit) + 24 (route) + 9 (admin) + 13 (page) checks.
What this does NOT fix, said out loud
ParaSend was not the only caller of the reveal route. Three pages still fetch
the raw key into the browser and use it as a relay credential:
/accountfrontend/js/account.inline1.js/pricingfrontend/js/pricing-billing.jsX-Api-KeyonPOST /v2/billing/checkout/dashboardfrontend/js/dashboard-history.jsX-Api-Keyon the usage and history readsSo the honest statement is: on
/parasharethe key is gone, and on a browserthat has loaded any of those three it is not. The first draft of the
/privacyparagraph in this PR said "not in your browser at all", which was true of one
page and false of three, and that is the same failure as the
paramant_api_keyline it replaced, one draft later./privacynow names allfour pages and says what each does, and site-claims row 36 holds the list from
both ends: a listed page that stops fetching the key fails, and a fourth page
that starts fetching it without being listed fails too.
SECURITY.mdcarries the same table under "what is still open", with whatclosing it would take:
/pricingand/dashboardneed scoped credentials oftheir own or server-side proxies, and
/accounthas to keep a way to show a keya self-hoster genuinely needs. Recorded rather than fixed, because that is a
separate change and a partial one would break three pages.
It also states the new ceiling plainly: a script on the site can still act as
the signed-in user for fifteen minutes, and this is a reduction, not a fix for
cross-site scripting.
Tests
relay/test/session-token.test.jsrelay/test/route-session-token.test.jsadmin/test/parasend-token.test.jstests/parasend-session-key.test.mjstests/site-claims.test.mjsrows 28 and 36relay/test/route-auth-gate.test.jsandv1-bearer-gate.test.jsThe route suite is the one that matters most: an upload made with a token
increments the owner's monthly counter and lands in the owner's audit chain, the
owner over its cap is the token over its cap, expiry is honoured on both the
store TTL and the wall clock inside the record, revocation works twice over, and
an
X-Api-Keyon the request always wins.Verified by sabotage, seventeen of them, each against the suite that should
catch it. Relay:
scopeAllowsreturning true,resolvereturning null insteadof throwing on a missing store, dropping the
expcheck, moving the TTL to anhour, dropping the revocation sweep, letting a Bearer override an
X-Api-Key,removing the scope gate. Admin: leaking the key next to the token, reading the
account from the body, forgetting the internal header, falling back to the key.
Frontend: sending
X-Api-Keyon the hosted path, dropping either refresh,making the retry unconditional (it never returns, which is the loop it forbids),
keeping the token when a key is typed, persisting the key again. Claims: moving
TTL_S, adding a sixth route to the allowlist, pointing the page back at/api/user/account/key, storing the token.