Skip to content

The ParaSend account key never reaches the browser: a scoped session token - #401

Merged
Apolloccrypt merged 7 commits into
mainfrom
feat/parasend-session-token
Sep 3, 2026
Merged

The ParaSend account key never reaches the browser: a scoped session token#401
Apolloccrypt merged 7 commits into
mainfrom
feat/parasend-session-token

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 3, 2026

Copy link
Copy Markdown
Owner

The security review of #397 accepted that /parashare had stopped keeping a key
in localStorage and then said the harder thing: the key should not be in the
browser at all. It named the answer, and this is it.

/parashare used to fetch the account API key and hold it in a variable for the
life 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. Fifteen
minutes. Five routes. No key.

Relay

relay/lib/session-token.js mints the tokens into the shared Redis rather than a
Map, 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, device
queues 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 the
sixty-ninth route cannot forget to ask: /v2/user/*, /v2/outbound,
/v2/audit, /v2/admin/*, the ParaSign routes, and a second mint.

POST /v2/session-token needs X-Internal-Auth and a live X-Api-Key, so
the 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 503 with Retry-After, never 401: a 401 in an outage
tells 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, behind authUser. It takes no body: the account
comes from the session through proxyApiKey, so a browser cannot name another
one. 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/key stays. It is not what /parashare uses any more, and
the 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-Key on the manual self-host
path. X-Api-Key now appears exactly once in parashare.page.js, and a test
holds 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)

  • The stored record names its owner by hash. It carried the api-key in the
    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 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 resolves the hash through an index over the api-key
    table it already holds in memory. Built on load and on /v2/reload-users, and
    self-healing on a miss, because keys are also created at run time by routes
    that touch neither.
  • The expiry is required. It was optional behind a typeof, so a record
    that 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 note
    on the credential, not a second identity: the chain stays keyed on the owner's
    api-key.
  • A ceiling of 20 live tokens per account, 429 with Retry-After. The
    sweep index is pruned of names redis already expired before a refusal is made,
    and the prune runs only when the cheap sCard says the cap is in reach.

Sabotages for each: put the key back in the record, restore the typeof guard
on exp, drop the audit field, set the cap to Infinity, make the prune remove
live 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 (SCAN over the
whole 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:

Page File What it does with the key
/account frontend/js/account.inline1.js reveals it on screen, deliberately
/pricing frontend/js/pricing-billing.js X-Api-Key on POST /v2/billing/checkout
/dashboard frontend/js/dashboard-history.js X-Api-Key on the usage and history reads

So the honest statement is: on /parashare the key is gone, and on a browser
that has loaded any of those three it is not. The first draft of the /privacy
paragraph 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_key line it replaced, one draft later. /privacy now names all
four 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.md carries the same table under "what is still open", with what
closing it would take: /pricing and /dashboard need scoped credentials of
their own or server-side proxies, and /account has to keep a way to show a key
a 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

Suite Checks Job
relay/test/session-token.test.js 19 relay unit (no redis, no deps)
relay/test/route-session-token.test.js 18 relay crypto/route (booted relay + redis)
admin/test/parasend-token.test.js 9 admin unit (booted admin + stub relay + redis)
tests/parasend-session-key.test.mjs 13 root integration (the page in a vm)
tests/site-claims.test.mjs rows 28 and 36 root integration
relay/test/route-auth-gate.test.js and v1-bearer-gate.test.js +2 the boundary between the two Bearer credentials

The 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-Key on the request always wins.

Verified by sabotage, seventeen of them, each against the suite that should
catch it. Relay: scopeAllows returning true, resolve returning null instead
of throwing on a missing store, dropping the exp check, moving the TTL to an
hour, 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-Key on 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.

…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
Apolloccrypt force-pushed the feat/parasend-session-token branch from 6b90243 to 417a20f Compare September 3, 2026 10:09
@Apolloccrypt Apolloccrypt changed the title The ParaSend key never reaches the browser: a scoped session token The ParaSend account key never reaches the browser: a scoped session token Sep 3, 2026
/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
Apolloccrypt force-pushed the feat/parasend-session-token branch from daa4868 to e112535 Compare September 3, 2026 10:20
@Apolloccrypt
Apolloccrypt marked this pull request as ready for review September 3, 2026 10:31
Apolloccrypt and others added 2 commits September 3, 2026 12:56
…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
Apolloccrypt merged commit 0fcbb74 into main Sep 3, 2026
15 checks passed
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.
@Apolloccrypt
Apolloccrypt deleted the feat/parasend-session-token branch September 5, 2026 18:56
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