Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,127 @@ Full report: [docs/security-audit-2026-04.md](docs/security-audit-2026-04.md)

---

### 2026-09-03: the ParaSend account key leaves the browser

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. This records what was done about it and, more usefully, what is
still true afterwards.

#### The old ceiling

`/parashare` fetched the account's API key from `GET /api/user/account/key` and
held it in a variable for the life of the tab. That key is a full data-plane
credential with no expiry and no scope. Anything that got to run script on
paramant.app could read it out of the page and keep it: upload and download on
the account, list and revoke its transfers, enrol a signing key, create ParaSign
envelopes, read the audit chain. Not for fifteen minutes. Until the owner
noticed and rotated the key, which is a thing an owner does when something has
already gone wrong.

The page's own hardening did not touch this. The key was never persisted and
never logged; it was simply present, in a variable, which is all an injected
script needs.

#### What replaced it

A `pst_` session token, minted by the admin panel on behalf of a logged-in user
and handed to the browser instead of the key. Three properties, and the second
is the one that matters:

- **Fifteen minutes.** Held in the relay's shared Redis, so all five sectors
honour the same token; not an operator knob, because a deployment that could
set this to a week would have rebuilt the credential this removes.
- **Five routes.** An allowlist in `relay/lib/session-token.js`, checked above
every route comparison in `relay.js`: `/v2/check-key`, `POST /v2/ws-ticket`,
`POST /v2/pubkey`, `GET /v2/pubkey/:device`, `POST /v2/inbound`. Everything
else is `403`, including `/v2/user/*`, `/v2/outbound`, `/v2/audit`,
`/v2/admin/*`, the ParaSign envelope routes, and a second mint: a token cannot
extend its own fifteen minutes.
- **The same account.** Inside that scope the token authenticates as the api-key
it was minted for, so quota, the audit chain and the tier ceilings resolve
against the owner. A token is a narrower way to present an account, never a
second account.

`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 another account.
Revoking the key sweeps its tokens out of the store, and a token whose owner key
is inactive grants no principal even when that sweep did not run: the sweep is
the fast path, not the guarantee.

#### Three things the review of this change tightened

- **The stored record names its owner by hash.** It used to carry 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: an RDB
snapshot, a replica, a backup on a laptop or a `MONITOR` session carried 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 by looking it up in the api-key table it already has in memory. Nothing
derives a key from a hash; a read-only copy of the store is a copy of hashes.
It also means key revocation and deletion arrive for free, because the lookup
is against the live table.
- **The expiry is required, not optional.** A record with no `exp`, or one whose
`exp` is not a number, is refused. It used to fall through a `typeof` guard to
whatever TTL redis happened to have on the key, which meant a credential whose
lifetime was a property of the store alone, and no lifetime at all when the
store was wrong.
- **A transfer made with a token is marked in the audit chain**, with one field,
`"via": "pst"`. Not a second identity: the chain is the owner's, keyed on the
owner's api-key exactly as for a request that carried the key. Without the
field an owner reading their own log cannot tell a transfer made from a
browser session apart from one made with the key itself, which is the
distinction that matters when they are working out what happened.

There is also a ceiling of 20 live tokens per account, answered with `429` and a
`Retry-After`. It is not a rate limit: the page mints one per load and one per
refresh, so twenty is far above honest use, and what it stops is a signed-in
session being run as a credential factory. Tokens already issued keep working,
and room returns as they expire; the sweep index is pruned of names redis has
already expired before a refusal is made, so nobody is refused on the strength
of tokens that are gone.

#### The new ceiling, stated plainly

**A script that runs on paramant.app can still act as the signed-in user for
fifteen minutes.** It can start a transfer, publish a handshake key and upload a
blob against the account's monthly quota. What it can no longer do is take the
key with it: it cannot read the account's downloads or audit log, cannot enrol a
signing identity, cannot create or sign an envelope, and cannot do any of it
after the token expires, because minting a new one requires the session cookie
to still be there and the mint route to be reached through the admin panel.

That is a real reduction and it is not a fix for cross-site scripting. The CSP
on the site and the escaping in the pages remain the thing that stops a script
running in the first place; this only bounds what one gets if it does.

#### What is still open

The `GET /api/user/account/key` reveal route still exists, and ParaSend was not
its only caller. 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 the 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 of the ceiling is this: on `/parashare` the key is gone,
and on a browser that has loaded any of those three pages it is not. The route
is reachable from any signed-in browser and answers with the raw key, so a
script with a session cookie can also simply ask for it.

Closing that is the next change, and it is not one line: `/pricing` and
`/dashboard` need scoped credentials of their own (or server-side proxies, which
is what `/api/user/documents` already does), and `/account` has to keep a way to
show a key that a self-hoster genuinely needs. Recorded here rather than fixed,
because a partial fix that removed the route would break three pages, and one
that left it while claiming the key is out of the browser would be the same kind
of untruth this section exists to correct.

---

### 2026-09-03: login lockout and TOTP replay (internal review of #367)

Two decisions came out of reviewing the site-claims work in #367, both about
Expand Down
49 changes: 49 additions & 0 deletions admin/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2484,6 +2484,55 @@ function maskIp(ip) {
// top of this file. Audit-chain records keep the full email on purpose (admin
// traceability); the masked form is only for stdout/journald.

// POST /api/user/parasend/token
//
// The route that lets ParaSend stop holding an api-key. /parashare asks for a
// pst_ session token, the relay mints one for the account this session is
// signed in as, and the browser is handed the token and nothing else.
//
// Three things this route does NOT do, each on purpose:
// * it takes no body. The account is the session's, read server-side through
// proxyApiKey(); a browser cannot name another one, so there is nothing to
// validate and nothing to get wrong;
// * it never returns the api-key, not even alongside the token. The whole
// point of the token is that the key stops travelling;
// * it does not fall back to the key when the relay cannot mint. A fallback
// would quietly put the credential back in the browser on exactly the days
// something is already wrong.
//
// GET /api/user/account/key below stays as it is. It is the reveal a
// self-hoster and the account page still need, and it is no longer what
// /parashare uses.
api.post("/user/parasend/token", authUser, async (req, res) => {
const key = proxyApiKey(req.userSession);
if (!key) return res.status(403).json({ error: "no_account_key" });
try {
const rr = await fetch(`${SECTORS.health}/v2/session-token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Auth": INTERNAL_TOKEN,
"X-Api-Key": key,
},
body: "{}",
signal: AbortSignal.timeout(10000),
});
const body = await rr.json().catch(() => ({ error: "bad_relay_response" }));
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private");
if (rr.status !== 200 || !body.token) {
// The relay's status is passed through so the page can tell an outage
// (503, worth a retry) from a refusal (401/403, worth the banner). The
// relay's body is not: it is written for an operator reading a log, and
// relaying it would put relay internals on a page anyone can open.
return res.status(rr.status === 200 ? 502 : rr.status).json({ error: "token_unavailable" });
}
return res.json({ token: body.token, expires_in_s: body.expires_in_s });
} catch (err) {
console.error("[user/parasend/token]", err.message);
return res.status(502).json({ error: "relay_unreachable" });
}
});

// GET /api/user/account/key
api.get("/user/account/key", authUser, async (req, res) => {
// stap 3: reveal the account's PRIMARY api-key (== user_id today), and only
Expand Down
13 changes: 12 additions & 1 deletion admin/test/_admin-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,23 @@ async function stubRelay(state) {
req.on('end', () => {
let body = null;
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch (_) { body = null; }
state.calls.push({ method: req.method, path: url.pathname, body });
// Headers are recorded as well as the body. The mint route below is
// authenticated entirely by headers the browser does not have, so a suite
// that could only see the body could not tell a correct call from one
// that forgot the internal token and would fail closed in production.
state.calls.push({ method: req.method, path: url.pathname, body, headers: req.headers });
const send = (status, payload) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
if (url.pathname === '/v2/admin/keys') return send(200, { keys: state.accounts });
// POST /v2/session-token: the ParaSend session-token mint. Only answered
// when a suite asked for it by setting state.mintReply, so every other
// suite keeps the 404 it has today.
if (url.pathname === '/v2/session-token' && typeof state.mintReply === 'function') {
const out = state.mintReply(req.headers, body || {});
return send(out.status || 200, out.body !== undefined ? out.body : {});
}
if (url.pathname === '/v2/user/verify-totp' || url.pathname === '/v2/user/consume-backup') {
const backup = url.pathname.endsWith('consume-backup');
const out = backup
Expand Down
Loading