diff --git a/SECURITY.md b/SECURITY.md index 3afcd1a6..6ad8aaed 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/admin/server.js b/admin/server.js index 562f07d8..8372c2b2 100644 --- a/admin/server.js +++ b/admin/server.js @@ -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 diff --git a/admin/test/_admin-server.js b/admin/test/_admin-server.js index d887121f..7a29c8ba 100644 --- a/admin/test/_admin-server.js +++ b/admin/test/_admin-server.js @@ -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 diff --git a/admin/test/parasend-token.test.js b/admin/test/parasend-token.test.js new file mode 100644 index 00000000..5affd6e3 --- /dev/null +++ b/admin/test/parasend-token.test.js @@ -0,0 +1,237 @@ +'use strict'; +// POST /api/user/parasend/token, on a really booted admin/server.js. +// +// WHY THIS SUITE EXISTS. keys-session.test.js next door is a pure-logic test of +// admin/lib/account-keys.js, and it is the right shape for a decision. This is +// not a decision, it is a boundary: the browser asks for a credential, the +// admin fetches it from the relay with headers no browser has, and hands back +// strictly less than it received. Every one of those properties is a property +// of a REQUEST, so this suite spawns the real server, gives it a real redis for +// its session store, and puts a stub relay behind it that records exactly what +// the admin sent. +// +// What is asserted, and why each line earns its place: +// * no session is 401, before the relay is touched at all. The relay is +// reachable in this suite, so a route that leaked would leak here; +// * a signed-in browser gets a token, and NOTHING else. Not the api-key, not +// alongside it, not in a field nobody reads. That is the entire feature; +// * the admin authenticates to the relay with the SESSION's key plus the +// internal header, and takes no account id from the request. A body naming +// someone else's account changes nothing; +// * a relay that refuses, breaks, or cannot be reached is passed through as a +// status the page can act on, with the relay's own words dropped; +// * and the fallback that must never exist: no failure path returns the key. +// +// Verified by sabotage: return `api_key` next to the token and two cases go +// red; read the account from req.body and the "a body cannot name an account" +// case goes red; drop the X-Internal-Auth header and the stub records a mint +// without it; relay 503 turned into a 200 with the key fails the outage case. +// Run: REDIS_URL=redis://127.0.0.1:6399 node --test admin/test/parasend-token.test.js + +const { test, before, after } = require('node:test'); +const assert = require('assert'); +const crypto = require('crypto'); +const { boot, killAll, stubRelay, defaultRelayState, summary } = require('./_admin-server'); + +const DEFAULT_REDIS = 'redis://127.0.0.1:6399'; +const INTERNAL = 'internal-token-for-the-parasend-token-suite'; +const SUFFIX = crypto.randomBytes(6).toString('hex'); +const ACCOUNT_KEY = `pgp_account_key_for_the_parasend_suite_${SUFFIX}`; +const OTHER_KEY = `pgp_other_account_key_${SUFFIX}`; +const MINTED = `pst_${'a'.repeat(64)}`; + +let redis = null; +let srv; +let relay; +let checks = 0; +const did = () => { checks++; }; + +// A redis for the admin's session store, and for planting a session directly. +// Logging in through the real flow would need TOTP and a captcha and would +// measure the login, not this route. +async function connectRedis() { + const url = process.env.REDIS_URL || DEFAULT_REDIS; + let createClient; + try { ({ createClient } = require('redis')); } + catch (e) { + throw new Error(`unmet precondition "redis": the "redis" module is not installed: ${e.message}\n` + + ' Run npm ci in admin/, or, if this job is deliberately without it: ADMIN_TEST_SKIP=redis'); + } + const rc = createClient({ url, socket: { connectTimeout: 800, reconnectStrategy: false } }); + rc.on('error', () => {}); + try { await rc.connect(); await rc.ping(); return rc; } + catch (e) { + try { await rc.disconnect(); } catch (_) { /* already gone */ } + if (String(process.env.ADMIN_TEST_SKIP || '').split(',').map(s => s.trim()).includes('redis')) return null; + throw new Error(`unmet precondition "redis": no reachable redis at ${url}: ${e.message}\n` + + ' Give the job a redis service, or: ADMIN_TEST_SKIP=redis'); + } +} + +// A session in the store, the shape admin/server.js writes at login. +async function session(key = ACCOUNT_KEY) { + const token = crypto.randomBytes(24).toString('hex'); + await redis.set(`paramant:user:session:${token}`, JSON.stringify({ + user_id: key, email: `owner-${SUFFIX}@example.com`, + primary_api_key: key, legacy_revealable: true, + }), { EX: 3600 }); + return { Cookie: `paramant_user_session=${token}` }; +} + +// The stub answers /v2/session-token as the relay does, and `mintReply` lets a +// test hand the admin any of the answers a real relay can give. +function relayState() { + const state = defaultRelayState([]); + state.mintReply = () => ({ status: 200, body: { ok: true, token: MINTED, expires_ms: Date.now() + 900_000, expires_in_s: 900 } }); + return state; +} + +before(async () => { + redis = await connectRedis(); + if (!redis) return; + relay = await stubRelay(relayState()); + // The stub answers the mint through state.mintReply, which relayState() set + // above, so no other suite in this directory sees the route at all. + srv = await boot({ + redisUrl: process.env.REDIS_URL || DEFAULT_REDIS, + relay, + internalToken: INTERNAL, + env: { RELAY_HEALTH: relay.base }, + }); +}); + +after(async () => { + await killAll(); + if (redis) { try { await redis.disconnect(); } catch (_) { /* already gone */ } } + summary('parasend-token', checks); +}); + +const mint = (headers = {}) => srv.post('/api/user/parasend/token', { headers }); +const mintCalls = () => relay.state.calls.filter(c => c.path === '/v2/session-token'); + +test('no session is 401, and the relay is never asked', async (t) => { + if (!redis) return t.skip('no redis'); + const before = mintCalls().length; + const r = await mint(); + assert.strictEqual(r.status, 401); + assert.deepStrictEqual(r.json, { error: 'unauthenticated' }); + assert.strictEqual(mintCalls().length, before, + 'an unauthenticated caller must not cause a mint: the relay is reachable in this suite, so a leak would leak here'); + + // A cookie that names no live session is the same 401, one word different, + // and still no mint. + const dead = await mint({ Cookie: `paramant_user_session=${crypto.randomBytes(24).toString('hex')}` }); + assert.strictEqual(dead.status, 401); + assert.strictEqual(mintCalls().length, before); + did(); +}); + +test('THE POINT: a signed-in browser gets a token, and the api-key is nowhere in the answer', async (t) => { + if (!redis) return t.skip('no redis'); + const r = await mint(await session()); + assert.strictEqual(r.status, 200, r.text); + assert.strictEqual(r.json.token, MINTED); + assert.strictEqual(r.json.expires_in_s, 900); + assert.ok(!r.text.includes(ACCOUNT_KEY), + 'the response carries the account key. This route exists so that it cannot.'); + assert.ok(!r.text.includes('pgp_'), 'nothing shaped like an api-key may leave this route'); + // Strictly less than the relay handed over: no relay internals, no expires_ms + // the page has no use for, no ok flag it does not read. + assert.deepStrictEqual(Object.keys(r.json).sort(), ['expires_in_s', 'token']); + assert.match(r.headers['cache-control'] || '', /no-store/, + 'a bearer credential must not be cached by a proxy or a back button'); + did(); +}); + +test('the admin authenticates to the relay with the SESSION key plus the internal header', async (t) => { + if (!redis) return t.skip('no redis'); + relay.state.calls.length = 0; + const r = await mint(await session()); + assert.strictEqual(r.status, 200, r.text); + const call = mintCalls().at(-1); + assert.ok(call, 'the admin must actually ask the relay'); + assert.strictEqual(call.method, 'POST'); + assert.strictEqual(call.headers['x-api-key'], ACCOUNT_KEY, + 'the relay must be told which account, and told it by the server from the session'); + assert.strictEqual(call.headers['x-internal-auth'], INTERNAL, + 'without the internal header the relay refuses, and a route that forgot it would fail closed but silently'); + did(); +}); + +test('a body cannot name an account: the session decides, always', async (t) => { + if (!redis) return t.skip('no redis'); + relay.state.calls.length = 0; + const r = await srv.post('/api/user/parasend/token', { + headers: await session(), + body: { user_id: OTHER_KEY, account_id: OTHER_KEY, key: OTHER_KEY }, + }); + assert.strictEqual(r.status, 200, r.text); + const call = mintCalls().at(-1); + assert.strictEqual(call.headers['x-api-key'], ACCOUNT_KEY, + 'a browser named another account and the admin passed it on. The account is the session, and only the session.'); + did(); +}); + +test('a relay that refuses is passed through as a status, without its words', async (t) => { + if (!redis) return t.skip('no redis'); + for (const status of [401, 403]) { + relay.state.mintReply = () => ({ status, body: { error: 'Invalid API key', hint: 'X-Api-Key: pgp_...' } }); + const r = await mint(await session()); + assert.strictEqual(r.status, status, `a relay ${status} must reach the page as ${status}`); + assert.deepStrictEqual(r.json, { error: 'token_unavailable' }); + assert.ok(!/X-Api-Key/.test(r.text), + 'the relay writes for an operator reading a log; that text must not land on a page anyone can open'); + } + relay.state.mintReply = relayState().mintReply; + did(); +}); + +test('a store outage reaches the page as 503, so the retry is a retry and not a re-login', async (t) => { + if (!redis) return t.skip('no redis'); + relay.state.mintReply = () => ({ status: 503, body: { error: 'redis_unavailable' } }); + const r = await mint(await session()); + assert.strictEqual(r.status, 503, r.text); + assert.deepStrictEqual(r.json, { error: 'token_unavailable' }); + assert.ok(!r.text.includes(ACCOUNT_KEY), + 'NO FALLBACK. Handing the key over when the mint fails would put the credential back in the browser on exactly the days something is already wrong.'); + relay.state.mintReply = relayState().mintReply; + did(); +}); + +test('a 200 with no token in it is a bad gateway, not a success', async (t) => { + if (!redis) return t.skip('no redis'); + for (const body of [{}, { ok: true }, { token: '' }, { token: null }]) { + relay.state.mintReply = () => ({ status: 200, body }); + const r = await mint(await session()); + assert.strictEqual(r.status, 502, `a 200 carrying ${JSON.stringify(body)} answered ${r.status}`); + assert.deepStrictEqual(r.json, { error: 'token_unavailable' }); + } + relay.state.mintReply = relayState().mintReply; + did(); +}); + +test('a relay that cannot be reached at all is 502, and says so once', async (t) => { + if (!redis) return t.skip('no redis'); + const offline = await boot({ + redisUrl: process.env.REDIS_URL || DEFAULT_REDIS, + internalToken: INTERNAL, + // Nothing listens here. RELAY_HEALTH is what this route calls. + env: { RELAY_HEALTH: 'http://127.0.0.1:1' }, + }); + t.after(() => offline.stop()); + const r = await offline.post('/api/user/parasend/token', { headers: await session() }); + assert.strictEqual(r.status, 502, r.text); + assert.deepStrictEqual(r.json, { error: 'relay_unreachable' }); + did(); +}); + +test('the reveal route still exists: the manual way out is not collateral damage', async (t) => { + if (!redis) return t.skip('no redis'); + // /parashare no longer calls it, but a self-hoster and the account page do, + // and removing it while removing its caller would be a silent breakage. + const r = await srv.get('/api/user/account/key', { headers: await session() }); + assert.strictEqual(r.status, 200, r.text); + assert.strictEqual(r.json.api_key, ACCOUNT_KEY); + assert.strictEqual(r.json.revealable, true); + did(); +}); diff --git a/docs/api.md b/docs/api.md index 602e3653..a60fb887 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,7 @@ Three credential types are in use across different API surfaces: | API key (`pgp_` prefix) | `X-Api-Key: pgp_your_key` | Data plane — uploads, downloads, CT log (developer clients) | | Operator key (`plk_` prefix) | `X-Api-Key: plk_your_key` | Data plane — unlimited throughput (operator license) | | DID signature | `X-DID: did:paramant:…` + `X-DID-Signature: ` | Data plane — **active fallback** when no `X-Api-Key` is sent (see Device Identity) | +| ParaSend session token (`pst_` prefix) | `Authorization: Bearer pst_…` | Data plane: the five ParaSend transfer routes only, 15 minutes, minted for a browser session (see below) | | Session cookie | `Cookie: paramant_user_session=` | `/api/user/*` endpoints — set automatically after TOTP login | | Admin token | `X-Admin-Token: ` | `/admin/api/admin/*` endpoints — admin panel only | @@ -35,6 +36,43 @@ Three credential types are in use across different API surfaces: > with the same care as API keys, and revoke the DID enrollment when a device > is retired or compromised. +> **ParaSend session tokens.** A `pst_` token is a narrow, short-lived stand-in +> for an account API key, so a browser never has to hold the key itself. It is +> minted by the admin panel on behalf of a logged-in user +> (`POST /api/user/parasend/token`, session cookie), which asks the relay for it +> over the internal channel; the browser is only ever handed the token, never +> the key. Properties, all enforced by the relay: +> +> - **Scope.** An allowlist, checked above every route handler. A token opens +> `/v2/check-key`, `POST /v2/ws-ticket`, `POST /v2/pubkey`, +> `GET /v2/pubkey/:device` and `POST /v2/inbound`, and nothing else. Any other +> path answers `403 session_token_out_of_scope`, including `/v2/user/*`, +> `/v2/outbound/:hash`, `/v2/audit`, `/v2/admin/*`, the ParaSign envelope +> routes, and a second `POST /v2/session-token`: a token cannot mint another. +> - **Identity.** Inside that scope the token authenticates **as the API key it +> was minted for**. Monthly quotas (`transfers_month`), the audit chain, +> device queues and per-tier limits all resolve against the owner's account, +> byte-identical to a request that carried the owner's `X-Api-Key`. +> - **Lifetime.** 15 minutes, held in the relay's shared Redis so all five +> sectors honour the same token. It is not configurable. The stored record +> carries the owner as a SHA-256 hash, never as an API key, and the relay +> resolves it against the key table it already holds in memory; a read-only +> copy of the store therefore contains no usable credential. A record without +> a numeric expiry is refused outright. +> - **Ceiling.** At most 20 live tokens per account. The 21st mint answers +> `429 session_token_cap_reached` with `Retry-After`; tokens already issued +> keep working, and room returns as they expire. +> - **Audit.** A transfer made with a token appears in the owner's audit chain +> like any other, with one extra field, `"via": "pst"`. It is a note on the +> credential, not a second identity. +> - **Revocation.** Revoking the API key deletes every live token for it, and a +> token whose owner key is revoked or deleted grants no principal even if that +> sweep did not run. +> - **Precedence.** A request that carries `X-Api-Key` is that key's request; the +> `Authorization` header is only read when no API key was sent. +> - **Outage.** With the store unreachable a token is answered `503 +> redis_unavailable` with `Retry-After`, never `401`. + CT log and STH endpoints are **public** — no credential required. The `/v2/auth/capabilities` endpoint is public and returns which authentication modes are enabled on this relay instance. @@ -438,6 +476,32 @@ curl https://relay.paramant.app/v2/pubkey/phone-001 \ # {"device_id":"phone-001","ecdh_pub":"…","kyber_pub":"…","registered_at":"…"} ``` +### POST /v2/session-token: mint a ParaSend session token (internal) + +Not reachable from a browser or from the public internet. It requires the +internal channel header **and** the account's API key, and the admin panel is +the only caller: it sends the key of the session that asked, so a browser can +never name an account other than the one it is signed in as. See the +Authentication section above for what the resulting token can and cannot do. + +```bash +curl -X POST https://health.paramant.app/v2/session-token \ + -H "X-Internal-Auth: $INTERNAL_AUTH_TOKEN" \ + -H "X-Api-Key: pgp_your_key" +# {"ok":true,"token":"pst_…","expires_ms":1767225600000,"expires_in_s":900} +``` + +| Status | Meaning | +|--------|---------| +| 200 | Token minted. The response never contains the API key. | +| 401 | Missing or wrong `X-Internal-Auth`, or no live account key. The two are not distinguishable. | +| 403 | The caller presented a session token; a token cannot mint another one. | +| 429 | The account already holds 20 live tokens. `Retry-After: 60`. | +| 503 | The relay store is unreachable, so no checkable token can be issued. | + +The browser-facing half of this is `POST /api/user/parasend/token` on the admin +panel (session cookie, no body, returns only `token` and `expires_in_s`). + --- ## Device Identity @@ -593,7 +657,7 @@ Notes: |------|---------| | 400 | Bad request — missing or invalid fields | | 401 | Invalid API key or signature | -| 403 | Forbidden — wrong API key for this blob | +| 403 | Forbidden: wrong API key for this blob, or `session_token_out_of_scope` | | 404 | No blob / no STH at that timestamp | | 429 | Rate limit exceeded | | 503 | ML-DSA-65 not available on this relay | diff --git a/frontend/js/parashare.page.js b/frontend/js/parashare.page.js index bc4f9083..ced83557 100644 --- a/frontend/js/parashare.page.js +++ b/frontend/js/parashare.page.js @@ -10,6 +10,13 @@ const RELAY_SECTORS = { let RELAY_API = RELAY_SECTORS.health; // updated after key validation let apiKey = '', keyValid = false, selectedFile = null, selectedFiles = []; +// The credential this page really runs on. `sessionAuth` is a pst_ session +// token: fifteen minutes, scoped by the relay to the five routes a transfer +// walks, and minted per browser session. `apiKey` above is now only ever the +// manual way out, for a self-hosted relay with no /api/user/parasend/token. +// Exactly one of the two is set; relayAuthHeaders() is the one place that +// decides which header goes on a request. +let sessionAuth = '', sessionAuthExp = 0, sessionAuthPending = null; // Sector discovery is its own question, kept apart from keyValid: a key can be // perfectly good while not one of the four sectors answers. relayReady says a // sector was found; relayError carries the reason it was not. @@ -18,8 +25,16 @@ let relayReady = false, relayError = ''; // be able to stop it: a second poll on a dead token would keep asking forever. let pubkeyPoll = null; // nginx puts /api/user/ in the relay_auth zone (burst 5). One fetch for the -// account key, and at most one retry, 2 s later, when that fetch is throttled. +// session token, and at most one retry, 2 s later, when that fetch is throttled. const KEY_RETRY_MS = 2000; +// Where the token comes from. The admin mints it against the account this +// browser is signed in as; the page never sees the account key. +const TOKEN_URL = '/api/user/parasend/token'; +// Refresh a minute before the relay would stop honouring it. A sender who +// spends twenty minutes choosing a 4 GB file and comparing a fingerprint must +// not hit an expiry at the upload, which is the one step that cannot be +// cheaply retried. +const TOKEN_MARGIN_MS = 60000; let sessionToken = '', ws = null; let receiverPubs = null; @@ -69,12 +84,16 @@ function setStepperStage(key) { } // Show the full API-key card. Two callers: the "Change" link in the slim row, -// and the way out on the error banner, which is the path a self-hoster without -// /api/user/account/key takes. +// and the way out on the error banner, which is the path a self-hoster whose +// deployment cannot mint a session token takes. function expandApiKeyCard() { var s = $('step-setup'); if (s) s.classList.add('manual-key'); setKeyError(false); + // Asking for the box means taking over from the session token, so the token + // is dropped here rather than left as a second credential the page might + // still reach for. One credential at a time, always. + sessionAuth = ''; sessionAuthExp = 0; sessionAuthPending = null; var inp = $('api-key'); if (inp) { inp.value = ''; inp.focus(); onKeyInput(); } // Legacy only. This page no longer writes the key to localStorage; this @@ -84,17 +103,15 @@ function expandApiKeyCard() { // The slim row is the default state of step 1. This fills it in once the // session key has really arrived: the mask, the label, and the green dot. -function applySlimApiKeyView() { - var inp = $('api-key'); - if (!inp || !inp.value) return; +function applySlimApiKeyView(shown) { + if (!shown) return; var mask = $('ps-key-mask'); if (mask) { - var v = inp.value; - mask.textContent = v.length > 14 ? v.slice(0, 8) + '...' + v.slice(-4) : v; + mask.textContent = shown.length > 14 ? shown.slice(0, 8) + '...' + shown.slice(-4) : shown; mask.hidden = false; } var label = $('ps-key-slim-label'); - if (label) label.textContent = 'Using your account key'; + if (label) label.textContent = 'Using your account'; var row = $('ps-key-slim'); if (row) { row.classList.remove('is-loading'); row.hidden = false; } var s = $('step-setup'); @@ -277,16 +294,106 @@ async function showReceiverConnected(kyberPub, ecdhPub) { renderFingerprintQR(fp); } +// ── The credential, and the one place that presents it ────────────────────── +// Every relay call on this page goes through relayFetch. Two reasons it is one +// function and not five copies: the header depends on which credential the page +// is running (a Bearer token from the session, or a hand-typed key on a +// self-host), and a token can die halfway through a long send. Spread over five +// call sites, one of them would eventually be written without the refresh. +function relayAuthHeaders(extra) { + var h = {}; + for (var k in (extra || {})) if (Object.prototype.hasOwnProperty.call(extra, k)) h[k] = extra[k]; + if (sessionAuth) h['Authorization'] = 'Bearer ' + sessionAuth; + else if (apiKey) h['X-Api-Key'] = apiKey; + return h; +} + +// Ask the admin for a token. The account is the one this browser is signed in +// as; there is nothing to send and nothing the page could name. +async function fetchSessionToken() { + var r = await fetch(TOKEN_URL, { method: 'POST', credentials: 'include' }); + // nginx rate-limits /api/user/ (zone relay_auth, burst 5). A 429 here is the + // page arriving next to its own siblings, not a broken account, so it earns + // exactly one retry and then gives up. + if (r.status === 429) { + await new Promise(function (res) { setTimeout(res, KEY_RETRY_MS); }); + r = await fetch(TOKEN_URL, { method: 'POST', credentials: 'include' }); + } + // Expected, and marked so: a browser with no session gets 401/403, and a + // self-host that never built this endpoint answers 404. Both mean "no token + // here", the banner is the whole answer, and neither is worth a line in the + // console of every signed-out visitor. + if (!r.ok) { + var refused = new Error('session token: HTTP ' + r.status); + refused.expected = r.status === 401 || r.status === 403 || r.status === 404; + throw refused; + } + var d = await r.json(); + if (!d || !d.token) { + var none = new Error('session token: none for this session'); + none.expected = true; + throw none; + } + return d; +} + +// One refresh at a time. Two uploads racing an expiry would otherwise mint two +// tokens and each keep the other's, and the loser would carry a token it had +// already replaced. +function refreshSessionAuth() { + if (sessionAuthPending) return sessionAuthPending; + sessionAuthPending = fetchSessionToken().then(function (d) { + sessionAuth = d.token; + sessionAuthExp = Date.now() + (d.expires_in_s || 0) * 1000; + sessionAuthPending = null; + return true; + }).catch(function () { + // The token is left as it was. A failed refresh is not proof the old one is + // dead, and throwing the credential away here would turn a blip in the + // admin into a dead page. + sessionAuthPending = null; + return false; + }); + return sessionAuthPending; +} + +// One relay call, with whichever credential this page is running, and exactly +// one recovery: a token that expired mid-session is replaced and the call is +// made again. Once. A 401 that survives a fresh token is a real 401. +async function relayFetch(url, opts) { + opts = opts || {}; + // A refresh already tried and failed on this call means minting is broken + // right now, and a 401 below is not something a second attempt will fix. Two + // mints per call, against an endpoint nginx allows a burst of five, is how a + // page turns one bad minute into a rate limit of its own. + var minted = true; + if (sessionAuth && sessionAuthExp && Date.now() > sessionAuthExp - TOKEN_MARGIN_MS) { + minted = await refreshSessionAuth(); + } + var send = {}; + for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k) && k !== 'retried') send[k] = opts[k]; + send.headers = relayAuthHeaders(opts.headers); + var r = await fetch(url, send); + if (r.status === 401 && sessionAuth && !opts.retried && minted) { + if (await refreshSessionAuth()) { + var again = {}; + for (var k2 in opts) if (Object.prototype.hasOwnProperty.call(opts, k2)) again[k2] = opts[k2]; + again.retried = true; + return relayFetch(url, again); + } + } + return r; +} + // ── Relay discovery: try all sectors in parallel, pick first valid ── // It used to fold two different failures into one null: "a sector answered and // refused this key" and "not one sector answered". The first is about the key, // the second is about the network, and only the first should ever disable the // button. So the two are reported apart. -async function discoverRelay(key) { +async function discoverRelay() { const results = await Promise.allSettled( Object.entries(RELAY_SECTORS).map(async ([sector, url]) => { - const r = await fetch(`${url}/v2/check-key`, { - headers: { 'X-Api-Key': key }, + const r = await relayFetch(`${url}/v2/check-key`, { signal: AbortSignal.timeout(5000) }); const d = await r.json(); @@ -303,34 +410,22 @@ async function discoverRelay(key) { }; } -// ── Key validation ── -// The key is never written to localStorage. It comes from the session -// (/api/user/account/key) or, on a self-host without that endpoint, from the -// manual card. Persisting it bought nothing and put a bearer credential in a -// store that outlives the sign-out that was documented to clear it. -async function onKeyInput() { - apiKey = $('api-key').value.trim(); - setCreateStatus(''); - // An empty box has nothing wrong with it yet. Calling it invalid is what the - // "Change" link did the moment it cleared the field: a red-flavoured verdict - // on a field the user had not filled in. - if (!apiKey) { - setStatus('key-status', 'Enter your API key to continue'); - keyValid = false; relayReady = false; relayError = ''; updateBtn(); return; - } - if (apiKey.length < 10 || !apiKey.startsWith('pgp_')) { - setStatus('key-status', 'That does not look like a key. It starts with pgp_.', 'err'); - keyValid = false; relayReady = false; relayError = ''; updateBtn(); return; - } - // A well-formed key that came from the session is usable now. Whether a - // sector answers is a separate question, and it is answered below without - // holding the button hostage. - keyValid = true; relayReady = false; relayError = ''; +// ── Credential validation ── +// Nothing is written to localStorage, and on the hosted relay nothing that +// reaches this page is an API key at all: the credential is a pst_ session +// token, minted per browser session and scoped by the relay to the five routes +// a transfer walks. The manual card below is the self-host path, and it is the +// only one that still holds a pgp_ key. +// +// The two paths share everything after the credential: find a sector that +// accepts the account, and report the result without holding the button +// hostage to a sector that will not answer. +async function discoverAndReport() { setStatus('key-status', 'Checking...'); updateBtn(); let d; try { - d = await discoverRelay(apiKey); + d = await discoverRelay(); } catch (e) { d = { answered: false, rejected: false, found: null }; } @@ -340,7 +435,10 @@ async function onKeyInput() { const sectorLabel = d.found.sector !== 'health' ? ` · ${d.found.sector}` : ''; setStatus('key-status', `✓ Valid, plan: ${d.found.plan}${sectorLabel}`, 'ok'); } else if (d.rejected) { - setStatus('key-status', 'Invalid or revoked key', 'err'); + // A sector answered and said no. On the session path that is a verdict on + // the account, not on anything the sender typed, so it is not called a bad + // key: there is no key here to be bad. + setStatus('key-status', sessionAuth ? 'This account is not active on any relay sector' : 'Invalid or revoked key', 'err'); keyValid = false; } else { // Nothing answered. Say so where the user is looking, and let the button @@ -351,6 +449,36 @@ async function onKeyInput() { updateBtn(); } +// The session path: a token is already in hand, so there is nothing to check +// about its shape and the sector question is the only one left. +async function useSessionToken() { + apiKey = ''; + keyValid = true; relayReady = false; relayError = ''; + await discoverAndReport(); +} + +// The manual card, for a self-hosted relay with no token endpoint. Typing a key +// takes over from the session token; expandApiKeyCard has already dropped it, +// and this drops it again for the case where the box was filled another way. +async function onKeyInput() { + sessionAuth = ''; sessionAuthExp = 0; sessionAuthPending = null; + apiKey = $('api-key').value.trim(); + setCreateStatus(''); + // An empty box has nothing wrong with it yet. Calling it invalid is what the + // "Change" link did the moment it cleared the field: a red-flavoured verdict + // on a field the user had not filled in. + if (!apiKey) { + setStatus('key-status', 'Enter your API key to continue'); + keyValid = false; relayReady = false; relayError = ''; updateBtn(); return; + } + if (apiKey.length < 10 || !apiKey.startsWith('pgp_')) { + setStatus('key-status', 'That does not look like a key. It starts with pgp_.', 'err'); + keyValid = false; relayReady = false; relayError = ''; updateBtn(); return; + } + keyValid = true; relayReady = false; relayError = ''; + await discoverAndReport(); +} + function setCreateStatus(msg, cls) { const el = $('create-status'); if (!el) return; @@ -388,7 +516,11 @@ async function createSession() { // answer, say so out loud instead of going quiet. if (!relayReady) { setCreateStatus('Looking for a relay sector...'); - await onKeyInput(); + // The sector question only, not the credential question. Calling onKeyInput + // here would re-read the manual box, and on the session path that box is + // empty by design: the retry would throw away a working token and report a + // missing key instead of a missing sector. + await discoverAndReport(); if (!relayReady) { // relayError is the planned sentence. Reaching here without one is a state // we did not plan for, so it gets the page's one sentence for that. @@ -417,7 +549,7 @@ async function connectWebSocket() { try { // Ticket must come from the same relay host the WS connects to (relay-main) const wsHttpBase = RELAY_WS.replace('wss://', 'https://').replace('ws://', 'http://'); - const tr = await fetch(`${wsHttpBase}/v2/ws-ticket`, {method:'POST',headers:{'X-Api-Key':apiKey},signal:AbortSignal.timeout(5000)}); + const tr = await relayFetch(`${wsHttpBase}/v2/ws-ticket`, {method:'POST',signal:AbortSignal.timeout(5000)}); if (tr.ok) { const td = await tr.json(); if (td.ticket) wsUrl += '?ticket=' + encodeURIComponent(td.ticket); } } catch {} ws = new WebSocket(wsUrl); @@ -433,7 +565,7 @@ async function connectWebSocket() { if (pubkeyPoll) clearInterval(pubkeyPoll); pubkeyPoll = setInterval(async () => { try { - const r = await fetch(`${RELAY_API}/v2/pubkey/${encodeURIComponent(sessionToken)}`, { headers: { 'X-Api-Key': apiKey }, signal: AbortSignal.timeout(3000) }); + const r = await relayFetch(`${RELAY_API}/v2/pubkey/${encodeURIComponent(sessionToken)}`, { signal: AbortSignal.timeout(3000) }); if (r.ok) { const d = await r.json(); if (d.kyber_pub && d.ecdh_pub) { @@ -550,9 +682,9 @@ async function confirmFingerprint() { const hashBuf = await crypto.subtle.digest('SHA-256', padded); const hash = u8toHex(new Uint8Array(hashBuf)); - const ur = await fetch(RELAY_API + '/v2/inbound', { + const ur = await relayFetch(RELAY_API + '/v2/inbound', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash, payload: toB64(padded), ttl_ms: ttlMs, // file_name omitted — filename is only in the encrypted payload (finding #4) @@ -584,9 +716,9 @@ async function confirmFingerprint() { $('enc-status').textContent = 'Notifying receiver...'; const isVault = files.length > 1; - await fetch(RELAY_API + '/v2/pubkey', { + await relayFetch(RELAY_API + '/v2/pubkey', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_id: sessionToken + '_ready', ecdh_pub: isVault ? JSON.stringify(vaultFiles) : vaultFiles[0].tokens.join(','), @@ -708,56 +840,37 @@ document.addEventListener('DOMContentLoaded', () => { return; } - loadAccountKey(); + loadSessionCredential(); // Small delay so DOM is fully painted before Globe.gl reads dimensions setTimeout(() => initGlobe(), 400); }); -// The session is the only source of the key. localStorage used to be a second -// one, and it is what the buyer review caught: a stale or hand-typed key sat -// there, the slim row said "using your account key", and Send died on a key -// that belonged to nobody. One source, one failure mode, one banner. -async function fetchAccountKey() { - let r = await fetch('/api/user/account/key', { credentials: 'include' }); - // nginx rate-limits /api/user/ (zone relay_auth, burst 5). A 429 here is the - // page arriving next to its own siblings, not a broken account, so it earns - // exactly one retry and then gives up. - if (r.status === 429) { - await new Promise(res => setTimeout(res, KEY_RETRY_MS)); - r = await fetch('/api/user/account/key', { credentials: 'include' }); - } - // Expected, and marked so: a browser with no session gets 401/403, and a - // self-host that never built this endpoint answers 404. Both mean "no key - // here", the banner is the whole answer, and neither is worth a line in the - // console of every signed-out visitor. - if (!r.ok) { - const noSession = new Error('account key: HTTP ' + r.status); - noSession.expected = r.status === 401 || r.status === 403 || r.status === 404; - throw noSession; - } - const d = await r.json(); - if (!d || !d.api_key) { - const noKey = new Error('account key: none on this session'); - noKey.expected = true; - throw noKey; - } - return d.api_key; -} - -async function loadAccountKey() { +// The session is the only source of the credential, and the credential is no +// longer the account key. The page asks the admin for a pst_ session token; the +// key stays on the server. What the buyer review caught first was a second +// source of truth in localStorage, and what the security review caught after it +// was the key itself: a credential with no expiry and no scope, held for the +// life of the tab, readable by anything that got to run on the page. There is +// one source, it hands over something that expires in fifteen minutes, and it +// opens five routes. +async function loadSessionCredential() { try { - const key = await fetchAccountKey(); - $('api-key').value = key; - applySlimApiKeyView(); - await onKeyInput(); + const d = await fetchSessionToken(); + sessionAuth = d.token; + sessionAuthExp = Date.now() + (d.expires_in_s || 0) * 1000; + // The slim row shows the token, masked, and not the key: there is no key + // here to show. The manual box stays empty, which is what makes the two + // credentials impossible to confuse. + applySlimApiKeyView(d.token); + await useSessionToken(); } catch (e) { setKeyError(true); // The banner carries the sentence either way. Only the unplanned half gets // reported: a 500, or a fetch that never arrived. Reporting the planned half // would put a console error on every signed-out page load, which is both // noise and a false alarm for the heartbeat that reads that console. - if (!e || !e.expected) failureText('account key', e); - setStatus('key-status', 'Account key could not be loaded', 'err'); + if (!e || !e.expected) failureText('session token', e); + setStatus('key-status', 'Account session could not be started', 'err'); keyValid = false; updateBtn(); } diff --git a/frontend/parashare.html b/frontend/parashare.html index 5b88bc2c..6f7b465c 100644 --- a/frontend/parashare.html +++ b/frontend/parashare.html @@ -45,7 +45,7 @@ .ps-guide strong{color:var(--ink-1);font-weight:600} .ps-guide .ps-guide-kicker{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px;font-weight:600} -/* The account key comes from the session and from nowhere else, so the slim row +/* The credential comes from the session and from nowhere else, so the slim row is the DEFAULT state of step 1 and the manual card is the exception. It used to be the other way round: the card was the default and the slim row appeared only after a key had been fetched, which is why a signed-in sender was shown @@ -70,7 +70,7 @@ #step-setup.manual-key .ps-key-slim{display:none} /* One alert style, used by both places on this page where something can fail - in front of the user: the account key that will not load, and the relay + in front of the user: the account session that will not start, and the relay connection that drops while step 2 is waiting. Each says what happened, what the file is doing meanwhile, and offers the one action that helps. */ .ps-alert{display:none;flex-direction:column;align-items:flex-start;gap:10px;margin-bottom:16px;padding:12px 14px;border:1px solid var(--line-2);border-left:3px solid var(--sig-stop);border-radius:var(--r-1);background:var(--sig-stop-bg);font:13px/1.62 var(--sans);color:var(--ink-1)} @@ -466,13 +466,13 @@

Send a file that deletes itself

Choose a file and say how long the link should live. The file stays on your own computer until step 4. We never hold it in a form we could open. -
- Loading your account key +
+ Starting your account session