From 62a99c0d9a6e3592696036098137deb08b765592 Mon Sep 17 00:00:00 2001 From: Apolloccrypt Date: Thu, 3 Sep 2026 12:08:39 +0200 Subject: [PATCH 1/6] Relay: a ParaSend session token, so the account key can leave the browser 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. --- docs/api.md | 55 +++- relay/lib/session-token.js | 169 ++++++++++ relay/relay.js | 122 ++++++- relay/test/route-session-token.test.js | 440 +++++++++++++++++++++++++ relay/test/session-token.test.js | 329 ++++++++++++++++++ 5 files changed, 1111 insertions(+), 4 deletions(-) create mode 100644 relay/lib/session-token.js create mode 100644 relay/test/route-session-token.test.js create mode 100644 relay/test/session-token.test.js diff --git a/docs/api.md b/docs/api.md index 602e3653..365985b8 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,33 @@ 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. +> - **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 +466,31 @@ 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. | +| 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 +646,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/relay/lib/session-token.js b/relay/lib/session-token.js new file mode 100644 index 00000000..fecf1161 --- /dev/null +++ b/relay/lib/session-token.js @@ -0,0 +1,169 @@ +'use strict'; +// ParaSend session tokens (pst_): a narrow, short-lived stand-in for an account +// api-key, so /parashare never has to hold a pgp_ key in the browser. +// +// WHAT PROBLEM THIS SOLVES. Until now the ParaSend page fetched the account's +// real api-key from GET /api/user/account/key and kept it in a variable for the +// life of the tab. That is a full data-plane credential with no expiry: any +// script that got to run on the page could read it, and then keep it. The +// security review of #397 said as much, and this is the answer it named. The +// page now asks the admin for a pst_ token, the token lives fifteen minutes, +// and it opens only the five routes a transfer actually walks. The XSS ceiling +// drops from "a key, forever" to "these five routes, for fifteen minutes". +// +// WHY REDIS AND NOT A MAP. The five sector relays are separate processes behind +// one shared redis (docker-compose.yml). A Map would mint on health and be +// unknown on legal, and the page discovers its sector at run time, so the token +// has to live where all five can see it. +// +// This module is the decision layer, with the redis client injected, so every +// rule below is unit-testable without a relay: relay/test/session-token.test.js +// covers it against a fake store, relay/test/route-session-token.test.js drives +// the real thing over HTTP. + +const crypto = require('crypto'); + +// Fifteen minutes. Long enough for a sender to pick a file, compare a +// fingerprint and upload it; short enough that a stolen token is a window and +// not a key. Not configurable on purpose: an operator who could set this to a +// week would silently rebuild the credential this module exists to remove. +const TTL_S = 900; + +const PREFIX = 'pst_'; +// 32 bytes of CSPRNG, hex. The shape is pinned here rather than guessed at the +// call sites, so a malformed Authorization header is refused before it ever +// reaches the store. +const TOKEN_RE = /^pst_[0-9a-f]{64}$/; + +// token -> owner record. +const tokenKey = (token) => `paramant:pst:${token}`; +// owner key -> the set of tokens minted for it, so a revocation can sweep them. +// The owner key is hashed: redis keyspace listings, SCAN output and slowlog +// entries all show key NAMES, and an api-key in a key name is an api-key in +// every one of those places. +const ownerKey = (key) => + `paramant:pst:owner:${crypto.createHash('sha256').update(String(key)).digest('hex')}`; + +function isSessionToken(value) { + return typeof value === 'string' && TOKEN_RE.test(value); +} + +// The token out of an Authorization header, or ''. Only the Bearer form is +// unwrapped, and only when it is the whole header: `Bearer a b` is not a token. +function bearerToken(header) { + if (typeof header !== 'string') return ''; + const m = /^Bearer[ \t]+([^\s]+)$/i.exec(header.trim()); + return m ? m[1] : ''; +} + +// ── Scope ──────────────────────────────────────────────────────────────────── +// An ALLOWLIST, deliberately, and the whole point of the feature. A pst_ token +// is not a small api-key, it is a different credential that happens to +// authenticate as the same account: it opens the five routes /parashare walks +// and nothing else. Anything not named here is refused, including routes that +// do not exist yet, which is what keeps this list honest as relay.js grows. +// +// What is NOT here, and why each absence matters: +// /v2/user/* the account's own session surface (TOTP, signing keys, +// document worklist). A token minted for a file transfer must +// never be able to enrol a signing key. +// /v2/keys anything that hands out or lists credentials. +// /v2/outbound downloading is the receiver's half of the flow and needs no +// account credential; letting a token do it would make a +// stolen token a way to drain the account's blobs. +// /v2/audit the account's history. +// /v2/admin/* never, under any credential but ADMIN_TOKEN. +// /v2/session-token itself: a token may not mint another one, so the fifteen +// minutes cannot be rolled forward from inside the browser. +const SCOPE = [ + // Sector discovery. The page races four sectors to find the one that accepts + // the account; the answer is valid/plan and nothing else. + { method: null, path: '/v2/check-key' }, + // The one-time ticket for the signalling socket. + { method: 'POST', path: '/v2/ws-ticket' }, + // Publishing the sender's half of the handshake, and reading the receiver's. + { method: 'POST', path: '/v2/pubkey' }, + { method: 'GET', re: /^\/v2\/pubkey\/[^/]+$/ }, + // The upload itself. + { method: 'POST', path: '/v2/inbound' }, +]; + +function scopeAllows(method, path) { + const m = String(method || '').toUpperCase(); + // A preflight carries no Authorization header, so it never gets here with a + // token; if one ever does, it is answered by the CORS handler, not by us. + if (m === 'OPTIONS') return true; + return SCOPE.some((rule) => { + if (rule.method && rule.method !== m) return false; + return rule.re ? rule.re.test(path) : rule.path === path; + }); +} + +// ── Store ──────────────────────────────────────────────────────────────────── + +// Mint a token for `owner` (an api-key). Returns { token, expires_ms, +// expires_in_s }. Throws when there is no store: a token that cannot be written +// must not be handed out, because the holder would then carry a credential no +// relay can check. +async function mint(redisClient, owner, now = Date.now()) { + if (!redisClient) throw new Error('session-token: no redis client'); + if (!owner || typeof owner !== 'string') throw new Error('session-token: no owner key'); + const token = PREFIX + crypto.randomBytes(32).toString('hex'); + const expires_ms = now + TTL_S * 1000; + // exp is stored INSIDE the record as well as being the redis TTL. Redis is + // what expires it; the field is what catches a record that outlived its TTL + // through a restore, a replica lag or a hand-written key. + await redisClient.set(tokenKey(token), JSON.stringify({ key: owner, exp: expires_ms }), { EX: TTL_S }); + // The sweep index. Its own TTL is the token's plus a minute, refreshed on + // every mint, so the set never outlives the last token it points at by more + // than that. Entries for tokens that already expired are harmless: the + // revocation deletes names, and deleting a name that is gone is a no-op. + await redisClient.sAdd(ownerKey(owner), token); + await redisClient.expire(ownerKey(owner), TTL_S + 60); + return { token, expires_ms, expires_in_s: TTL_S }; +} + +// The owner key a token stands for, or null. Null covers every refusal there +// is: a malformed token, one that expired, one that was revoked, and one whose +// record is not the shape this module writes. The caller cannot tell them +// apart, and must not: that separation would be an oracle for guessing tokens. +// +// A redis failure is NOT null. It throws, and the route turns that into a 503, +// because answering 401 on an outage would tell a legitimate holder their token +// is bad and send them to re-authenticate over a store that is merely down. +async function resolve(redisClient, token, now = Date.now()) { + if (!isSessionToken(token)) return null; + if (!redisClient) throw new Error('session-token: no redis client'); + const raw = await redisClient.get(tokenKey(token)); + if (!raw) return null; + let rec; + try { rec = JSON.parse(raw); } catch (_) { return null; } + if (!rec || typeof rec.key !== 'string' || !rec.key) return null; + if (typeof rec.exp === 'number' && now > rec.exp) return null; + return { key: rec.key, expires_ms: typeof rec.exp === 'number' ? rec.exp : null }; +} + +// Every live token for one api-key, gone. Called when the key is revoked. +// +// This is belt AND braces, and both halves are load-bearing. The braces are +// here: the tokens are deleted, so they stop resolving on every sector at once. +// The belt is in relay.js: a resolved token is looked up in apiKeys like any +// other credential, so a token whose owner is no longer an active key yields no +// principal even if this sweep never ran (a sector that was restarting, a redis +// that was briefly unreachable). Neither alone is enough; a revocation that +// depends on a best-effort write is not a revocation. +async function revokeForKey(redisClient, owner) { + if (!redisClient || !owner) return 0; + const idx = ownerKey(owner); + const tokens = await redisClient.sMembers(idx); + if (tokens && tokens.length) await redisClient.del(tokens.map(tokenKey)); + await redisClient.del(idx); + return (tokens || []).length; +} + +module.exports = { + TTL_S, PREFIX, SCOPE, + isSessionToken, bearerToken, scopeAllows, + mint, resolve, revokeForKey, + tokenKey, ownerKey, +}; diff --git a/relay/relay.js b/relay/relay.js index 3fcf8969..1caaa1c7 100644 --- a/relay/relay.js +++ b/relay/relay.js @@ -33,6 +33,7 @@ const redisCounter = require('./lib/redis-counter'); // INCR that always carr const rateLimit = require('./lib/rate-limit'); const authThrottle = require('./lib/auth-throttle'); const authGate = require('./lib/auth-gate'); +const sessionTokens = require('./lib/session-token'); // pst_ ParaSend session tokens const userSigning = require('./lib/user-signing'); const userWebauthn = require('./lib/user-webauthn'); const tiers = require('./lib/tiers'); @@ -210,7 +211,7 @@ const ALLOWED = { '/v2/webhook','/v2/audit','/v2/check-key','/v2/stream', '/v2/ack','/v2/monitor', '/v2/did','/v2/ct','/v2/attest','/v2/admin','/metrics','/v2/dl', - '/v2/key-sector','/v2/team','/v2/reload-users','/v2/session', + '/v2/key-sector','/v2/team','/v2/reload-users','/v2/session','/v2/session-token', '/v2/ws-ticket','/v2/fingerprint','/v2/relays','/v2/sign-dpa', '/v2/sth','/v2/verify-receipt','/v2/transfers','/v2/capabilities','/v2/health','/ct','/ct/feed','/v2/auth','/v2/user','/v2/setup', '/v2/sign','/v2/verify','/v2/lookup-signer','/v2/envelopes','/v2/billing','/v2/claim','/v2/parasign','/v1'], @@ -218,7 +219,7 @@ const ALLOWED = { '/v2/webhook','/v2/audit','/v2/check-key','/v2/stream','/v2/stream-next', '/v2/ack','/v2/monitor', '/v2/did','/v2/ct','/v2/attest','/v2/admin','/metrics','/v2/dl', - '/v2/key-sector','/v2/team','/v2/reload-users','/v2/session', + '/v2/key-sector','/v2/team','/v2/reload-users','/v2/session','/v2/session-token', '/v2/relays','/v2/sign-dpa','/v2/sth','/v2/verify-receipt','/v2/transfers', '/v2/capabilities','/v2/health','/ct','/ct/feed','/v2/auth','/v2/user','/v2/setup', '/v2/sign','/v2/verify','/v2/lookup-signer','/v2/envelopes','/v2/billing','/v2/claim','/v2/parasign','/v1'], @@ -2485,7 +2486,13 @@ async function handleRelayRequest(req, res) { const parsed = url_.parse(req.url, true); const path = parsed.pathname; const query = parsed.query; - const apiKey = (req.headers['x-api-key'] || '').trim(); + // `let`, not `const`: a ParaSend session token (pst_) resolves BELOW into the + // api-key it was minted for, and everything downstream -- acctOf, the audit + // chain, the device queues, the quota gates -- then behaves exactly as it + // would for a request that carried that key itself. That identity is the + // point: a token is a narrower way to present the same account, never a + // second account with a history of its own. + let apiKey = (req.headers['x-api-key'] || '').trim(); // Reject any request that passes the API key as a query-string parameter. // Query strings appear in server logs, browser history, and proxy access logs. if (query.k) { @@ -2503,6 +2510,34 @@ async function handleRelayRequest(req, res) { didAuthEntry = authByDid(didHeader, didSig, { method: req.method, url: req.url, ts: didTs, nonce: didNonce }); if (didAuthEntry) log('info', 'did_auth_mode', { did: didHeader.slice(0,30) }); } + // ── ParaSend session token (Authorization: Bearer pst_...) ───────────────── + // Only when no X-Api-Key was sent: a request that carries a real key is that + // key's request, and a token may never widen or narrow it. The token resolves + // to the api-key it was minted for; from here on the request IS that key's, + // with one difference, enforced a few lines down: the scope allowlist. + // + // The refusals are all silent by design. A bad token yields no principal and + // the ordinary 401 gate answers it, the same 401 an unknown api-key gets, so + // nothing here separates "no such token" from "expired" from "revoked". + // + // Redis down is NOT a refusal. Without a store no token can be checked, and + // answering 401 would tell a legitimate holder their credential is bad. It is + // a 503, so the browser retries instead of throwing the sender back to login. + const _bearer = sessionTokens.bearerToken(req.headers['authorization'] || ''); + let viaSessionToken = false; + if (!apiKey && sessionTokens.isSessionToken(_bearer)) { + if (!redisClient) { + res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '5' }); + return res.end(J({ error: 'redis_unavailable', hint: 'session tokens need the relay store' })); + } + try { + const _pst = await sessionTokens.resolve(redisClient, _bearer); + if (_pst) { apiKey = _pst.key; viaSessionToken = true; } + } catch (err) { + if (redisOutage503(err, res)) return; + throw err; + } + } const dsaSig = req.headers['x-dsa-signature'] || ''; // DID-auth NEVER mints its own principal. A DID only authenticates as the API // key it was REGISTERED under: inherit that key's real plan/active. Keyless DIDs @@ -2547,6 +2582,25 @@ async function handleRelayRequest(req, res) { } if (!modeAllows(path)) { res.writeHead(405); return res.end(J({ error: 'Not available in this relay mode', mode: RELAY_MODE })); } + // ── The scope of a ParaSend session token ────────────────────────────────── + // Here, and not inside the five routes it opens. A gate that each route had + // to remember to call is a gate that the sixty-ninth route forgets, and the + // whole value of this credential is that it is provably narrower than an + // api-key. So it sits above every route comparison in this function: an + // allowlist, checked once, with no way past it. + // + // 403 rather than 401 on purpose. The token is real and the account is real; + // what is missing is authority for THIS route, and a 401 would send the page + // off to mint a replacement that would be refused in exactly the same way. + if (viaSessionToken && !sessionTokens.scopeAllows(req.method, path)) { + log('warn', 'session_token_out_of_scope', { method: req.method, path }); + res.writeHead(403, { 'Content-Type': 'application/json' }); + return res.end(J({ + error: 'session_token_out_of_scope', + hint: 'a pst_ session token opens the ParaSend transfer routes only; use an API key for anything else', + })); + } + // ── Code-transparency manifest: publiek leesbaar, vóór de /v1-Bearer-gate ─── // The SHA3-256 inventory of the deployed frontend, CT-anchored on publish. // Independent monitors fetch this and compare it against the live assets. @@ -2924,6 +2978,56 @@ async function handleRelayRequest(req, res) { res.end(J({ error: "unauthorized" })); } + // ── POST /v2/session-token: mint a ParaSend session token ────────────────── + // Called by the admin panel on behalf of a logged-in user, never by a + // browser. Two credentials have to line up: + // + // X-Internal-Auth the admin plane speaking, same gate as every /v2/user/* + // route. Not configured means closed (auth-gate treats a + // missing token as closed), like every internal endpoint. + // X-Api-Key the account the token will speak for. The admin sends + // the session's own key through proxyApiKey(), so the + // browser never gets to name an account: it can only ever + // be handed a token for the account it is signed in as. + // + // A token may not mint another token. It cannot reach here anyway -- the + // scope allowlist refuses this path far above -- but the check is written out + // rather than inferred, because "an unreachable path" is a property of code + // somewhere else and this is the line that must not be wrong. + if (req.method === 'POST' && path === '/v2/session-token') { + if (!_internalOk()) return _internalReject(); + if (viaSessionToken) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + return res.end(J({ error: 'session_token_out_of_scope', hint: 'a session token cannot mint another one' })); + } + const owner = apiKeys.get(apiKey); + if (!owner || !owner.active) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + return res.end(J({ error: 'Invalid API key', hint: 'X-Api-Key: pgp_...' })); + } + if (!redisClient) { + res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '5' }); + return res.end(J({ error: 'redis_unavailable', hint: 'session tokens need the relay store' })); + } + try { + const minted = await sessionTokens.mint(redisClient, apiKey); + log('info', 'session_token_minted', { + account: String(owner.account_id || apiKey).slice(0, 12), + ttl_s: minted.expires_in_s, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(J({ + ok: true, + token: minted.token, + expires_ms: minted.expires_ms, + expires_in_s: minted.expires_in_s, + })); + } catch (err) { + if (redisOutage503(err, res)) return; + throw err; + } + } + // POST /v2/user/setup-totp if (req.method === "POST" && path === "/v2/user/setup-totp") { if (!_internalOk()) return _internalReject(); @@ -5584,6 +5688,18 @@ async function handleRelayRequest(req, res) { ud.updated = new Date().toISOString(); }).then(() => log('info', 'key_revoked_via_admin', { key: revokedKey.slice(0,16), persisted: true })) .catch(we => log('warn', 'key_revoke_persist_failed', { err: we.message })); + // Every live ParaSend session token for this key, gone from the shared + // store, so the other four sectors stop honouring them too. Not awaited: + // the revocation itself is already done above (the key is inactive in + // this process and being persisted), and a slow redis must not hold up + // the answer. It is also not the only thing standing between a revoked + // key and a live token -- a resolved token is looked up in apiKeys like + // any other credential, so this sweep is the fast path, not the guarantee. + if (redisClient) { + sessionTokens.revokeForKey(redisClient, revokedKey) + .then(n => { if (n) log('info', 'session_tokens_revoked', { key: revokedKey.slice(0, 16), count: n }); }) + .catch(e => log('warn', 'session_token_revoke_failed', { err: e.message })); + } // Fix 12: close active WebSocket connections for the revoked key const revokedWsClients = wsClients.get(revokedKey); if (revokedWsClients) { diff --git a/relay/test/route-session-token.test.js b/relay/test/route-session-token.test.js new file mode 100644 index 00000000..9e30a7f0 --- /dev/null +++ b/relay/test/route-session-token.test.js @@ -0,0 +1,440 @@ +'use strict'; +// ParaSend session tokens over HTTP, on a really booted relay with a real redis. +// +// WHY THIS SUITE EXISTS. The security review of #397 said the account key must +// stop reaching the browser at all, and this is the credential that replaces +// it. Everything that makes it safer than the key is a property of a REQUEST: +// which routes it opens, which account it counts against, what happens when the +// key behind it is revoked, and what the relay says when the store is gone. +// None of that can be read off the source, so none of it is asserted from the +// source here. +// +// The pure decisions (the allowlist, the token shape, the store contract) are +// session-token.test.js, which needs nothing and runs in the unit job. This one +// needs redis and the installed deps, so it rides in the route job. +// +// What is pinned, and why: +// * minting needs BOTH X-Internal-Auth and a live X-Api-Key, and a relay with +// no INTERNAL_AUTH_TOKEN mints nothing at all (fail closed); +// * a token authenticates AS the owner key on the five ParaSend routes; +// * and on nothing else: /v2/keys, /v2/user/*, /v2/outbound, /v2/audit, +// /v2/admin/* and a second mint are all 403, above every route handler; +// * QUOTA AND AUDIT SEE THE OWNER. An upload made with a token increments the +// owner's monthly counter and lands in the owner's audit chain, and the +// owner's 402 over quota is the token's 402 too. If this were wrong the +// token would be a free account hiding inside a paid one; +// * expiry is honoured, in both directions: the store's TTL and the wall +// clock inside the record; +// * revoking the key kills every live token, and does so twice over: swept +// from the store, and refused on lookup even when the sweep is undone; +// * an X-Api-Key on the request always wins, so a token can never widen or +// narrow a request that carries a real key; +// * redis unreachable is 503 with Retry-After, never 401. +// +// Run: REDIS_URL=redis://127.0.0.1:6399 node --test relay/test/route-session-token.test.js + +const { test, before, after } = require('node:test'); +const assert = require('assert'); +const crypto = require('crypto'); +const { boot, killAll } = require('./_relay-server'); +const { requireRedis, summary } = require('./_requires'); +const sessionTokens = require('../lib/session-token'); +const quota = require('../lib/quota'); + +const DEFAULT_REDIS = 'redis://127.0.0.1:6399'; +const INTERNAL = 'internal-token-for-the-session-token-suite'; +const ADMIN = 'admin-token-for-the-session-token-suite'; +// Fresh per run: this suite shares one redis with every other route suite, and +// the quota counters below are keyed on the account id. +const SUFFIX = crypto.randomBytes(6).toString('hex'); +const OWNER = `pgp_owner_key_for_the_session_token_suite_${SUFFIX}`; +const OWNER_ACCT = `acct_sesstok_${SUFFIX}`; +const DEAD = `pgp_dead_key_for_the_session_token_suite_${SUFFIX}`; +const REVOKE_ME = `pgp_revoke_key_for_the_session_token_suite_${SUFFIX}`; +const REVOKE_ACCT = `acct_sesstok_rev_${SUFFIX}`; + +let rc = null; // this suite's own redis handle, for planting and reading +let srv; // relay with INTERNAL_AUTH_TOKEN + ADMIN_TOKEN + redis +let noInternal; // relay with neither, to prove the mint fails closed +let checks = 0; +const did = () => { checks++; }; + +const users = () => ({ + api_keys: [ + { key: OWNER, plan: 'community', active: true, email: 'owner@example.test', account_id: OWNER_ACCT }, + { key: DEAD, plan: 'pro', active: false, email: 'dead@example.test', account_id: `acct_dead_${SUFFIX}` }, + { key: REVOKE_ME, plan: 'community', active: true, email: 'rev@example.test', account_id: REVOKE_ACCT }, + ], +}); + +before(async () => { + rc = await requireRedis(DEFAULT_REDIS); + const env = { + INTERNAL_AUTH_TOKEN: INTERNAL, + ADMIN_TOKEN: ADMIN, + REDIS_URL: process.env.REDIS_URL || DEFAULT_REDIS, + }; + srv = await boot({ tag: 'sesstok', users: users(), env, usersFile: true }); + noInternal = await boot({ tag: 'sesstok-noint', users: users(), env: { REDIS_URL: env.REDIS_URL } }); +}); + +after(async () => { + await killAll(); + if (rc) { try { await rc.disconnect(); } catch (_) { /* already gone */ } } + summary('route-session-token', checks); +}); + +// Mint the way the admin does: the internal header plus the session's own key. +async function mint(key = OWNER, server = srv) { + const r = await server.post('/v2/session-token', { + headers: { 'X-Internal-Auth': INTERNAL, 'X-Api-Key': key }, + }); + return r; +} +const bearer = (token) => ({ Authorization: `Bearer ${token}` }); + +// A fresh blob every time: the store is keyed on the sha256 and rejects a +// repeat with 409. +function blob(label) { + const payload = Buffer.from(`${label}-${crypto.randomBytes(8).toString('hex')}`); + return { payload, hash: crypto.createHash('sha256').update(payload).digest('hex') }; +} +const upload = (headers, b) => srv.post('/v2/inbound', { + headers, body: { hash: b.hash, payload: b.payload.toString('base64') }, +}); + +// ── 1. Minting ─────────────────────────────────────────────────────────────── + +test('the admin mints a token with the internal header and the account key', async (t) => { + if (!rc) return t.skip('no redis'); + const r = await mint(); + assert.strictEqual(r.status, 200, r.text); + assert.strictEqual(r.json.ok, true); + assert.match(r.json.token, /^pst_[0-9a-f]{64}$/); + assert.strictEqual(r.json.expires_in_s, 900, 'fifteen minutes, the number SECURITY.md states'); + assert.ok(r.json.expires_ms > Date.now(), 'the expiry is in the future'); + assert.ok(!r.text.includes(OWNER), 'THE WHOLE POINT: the mint response must never carry the api-key'); + did(); +}); + +test('the mint needs the internal header, and needs it to be right', async (t) => { + if (!rc) return t.skip('no redis'); + for (const headers of [ + { 'X-Api-Key': OWNER }, + { 'X-Api-Key': OWNER, 'X-Internal-Auth': '' }, + { 'X-Api-Key': OWNER, 'X-Internal-Auth': INTERNAL + 'x' }, + // The admin token is a different credential and must not stand in for it. + { 'X-Api-Key': OWNER, 'X-Internal-Auth': ADMIN }, + ]) { + const r = await srv.post('/v2/session-token', { headers }); + assert.strictEqual(r.status, 401, `${JSON.stringify(headers)} minted a token`); + assert.deepStrictEqual(r.json, { error: 'unauthorized' }); + } + did(); +}); + +test('the mint needs a LIVE account key, and says nothing about which failed', async (t) => { + if (!rc) return t.skip('no redis'); + const none = await srv.post('/v2/session-token', { headers: { 'X-Internal-Auth': INTERNAL } }); + const unknown = await mint('pgp_no_such_key_anywhere'); + const revoked = await mint(DEAD); + for (const [name, r] of [['no key', none], ['unknown key', unknown], ['revoked key', revoked]]) { + assert.strictEqual(r.status, 401, `${name} minted a token`); + } + assert.deepStrictEqual(revoked.json, unknown.json, + 'a de-activated key must answer exactly like an unknown one, or the mint is a key-existence oracle'); + did(); +}); + +test('a relay with no INTERNAL_AUTH_TOKEN mints nothing: unconfigured is closed, not open', async (t) => { + if (!rc) return t.skip('no redis'); + for (const headers of [{ 'X-Api-Key': OWNER }, { 'X-Api-Key': OWNER, 'X-Internal-Auth': 'anything' }]) { + const r = await noInternal.post('/v2/session-token', { headers }); + assert.strictEqual(r.status, 401, `unconfigured relay answered ${r.status}`); + } + did(); +}); + +// ── 2. The token is the owner, on the routes it opens ──────────────────────── + +test('a token authenticates as the owner on all five ParaSend routes', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + const h = bearer(token); + + // Sector discovery. This is the one that used to read the raw X-Api-Key + // header rather than the resolved principal: with a token it would have + // reported valid:false and the page would have refused its own credential. + const ck = await srv.get('/v2/check-key', { headers: h }); + assert.strictEqual(ck.status, 200); + assert.deepStrictEqual(ck.json, { valid: true, plan: 'community' }, + 'a token must report the owner as valid, with the owner plan'); + + const ticket = await srv.post('/v2/ws-ticket', { headers: h }); + assert.strictEqual(ticket.status, 200, ticket.text); + assert.match(ticket.json.ticket, /^wst_/); + + const device = `inv_${crypto.randomBytes(16).toString('hex')}`; + const pub = await srv.post('/v2/pubkey', { + headers: h, body: { device_id: device, ecdh_pub: 'aa'.repeat(32), kyber_pub: 'bb'.repeat(32) }, + }); + assert.strictEqual(pub.status, 200, pub.text); + + const read = await srv.get(`/v2/pubkey/${device}`, { headers: h }); + assert.strictEqual(read.status, 200, read.text); + assert.strictEqual(read.json.ecdh_pub, 'aa'.repeat(32)); + + const up = await upload(h, blob('token-upload')); + assert.strictEqual(up.status, 200, up.text); + assert.strictEqual(up.json.ok, true); + assert.ok(up.json.download_token, 'the upload really stored a blob'); + did(); +}); + +// ── 3. And on nothing else ─────────────────────────────────────────────────── + +test('THE SCOPE: every route outside the transfer path is 403, above the handler', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + const h = bearer(token); + const shut = [ + // Credentials. + ['GET', '/v2/keys', undefined], + // The account's own session surface. + ['GET', '/v2/user/signing-key', undefined], + ['POST', '/v2/user/signing-key', { user_id: OWNER, public_key: 'AA==' }], + ['POST', '/v2/user/envelopes', { user_id: OWNER }], + ['GET', '/v2/user/history', undefined], + ['POST', '/v2/user/setup-totp', { user_id: OWNER }], + // The receiver's half, and the account's history. + ['GET', `/v2/outbound/${'a'.repeat(64)}`, undefined], + ['GET', '/v2/audit', undefined], + // Admin. + ['GET', '/v2/admin/keys', undefined], + ['POST', '/v2/admin/keys/revoke', { key: OWNER }], + // ParaSign, a different product on the same account. + ['POST', '/v2/envelopes', { doc_hash: 'a'.repeat(64), parties: [{ label: 'Demo' }] }], + ]; + for (const [method, path, body] of shut) { + const r = await srv.req(method, path, { headers: h, body }); + assert.strictEqual(r.status, 403, `${method} ${path} answered ${r.status}, not 403: ${r.text}`); + assert.strictEqual(r.json.error, 'session_token_out_of_scope'); + } + did(); +}); + +test('a token cannot mint another token, even holding the internal header', async (t) => { + if (!rc) return t.skip('no redis'); + // The fifteen minutes are a ceiling on what a script that got onto the page + // can do. A token that could roll them forward would have no ceiling at all. + const { token } = (await mint()).json; + const r = await srv.post('/v2/session-token', { + headers: { ...bearer(token), 'X-Internal-Auth': INTERNAL }, + }); + assert.strictEqual(r.status, 403, r.text); + assert.strictEqual(r.json.error, 'session_token_out_of_scope'); + did(); +}); + +test('the refusal is a scope refusal, not a 404: the routes exist and stay reachable with the key', async (t) => { + if (!rc) return t.skip('no redis'); + // A 403 that was really "no such route" would make the scope test vacuous. + // The same paths, with the owner's real api-key, must not be 403. + const withKey = await srv.get('/v2/audit', { headers: { 'X-Api-Key': OWNER } }); + assert.strictEqual(withKey.status, 200, `the audit route must work with the key: ${withKey.text}`); + const admin = await srv.get('/v2/admin/keys', { headers: { 'X-Admin-Token': ADMIN } }); + assert.strictEqual(admin.status, 200); + did(); +}); + +// ── 4. Quota and audit see the owner ───────────────────────────────────────── + +test('QUOTA: an upload made with a token counts on the owner account', async (t) => { + if (!rc) return t.skip('no redis'); + const counter = quota.transfersKey(OWNER_ACCT); + const before = parseInt((await rc.get(counter)) || '0', 10); + + const { token } = (await mint()).json; + const up = await upload(bearer(token), blob('quota-owner')); + assert.strictEqual(up.status, 200, up.text); + + const after = parseInt((await rc.get(counter)) || '0', 10); + assert.strictEqual(after, before + 1, + `the transfer must be counted on ${OWNER_ACCT}; a token that counted somewhere else would be a free account hiding inside a paid one`); + did(); +}); + +test('QUOTA: the owner over its monthly cap is the token over its cap too', async (t) => { + if (!rc) return t.skip('no redis'); + // community transfers_month is 10 (relay/lib/tiers.js). Planted directly, so + // the suite does not have to spend ten uploads to reach it. + const counter = quota.transfersKey(OWNER_ACCT); + const saved = await rc.get(counter); + await rc.set(counter, '10', { EX: 300 }); + try { + const { token } = (await mint()).json; + const r = await upload(bearer(token), blob('quota-over')); + assert.strictEqual(r.status, 402, `over the cap the token must be declined like the key: ${r.text}`); + assert.strictEqual(r.json.error, 'monthly_transfer_quota_reached'); + assert.strictEqual(r.json.dimension, 'transfers_month'); + assert.strictEqual(r.json.limit, 10); + } finally { + if (saved === null) await rc.del(counter); else await rc.set(counter, saved, { EX: 300 }); + } + did(); +}); + +test('AUDIT: the upload lands in the owner chain, under the owner key', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + const b = blob('audit-owner'); + assert.strictEqual((await upload(bearer(token), b)).status, 200); + + // Read the chain the way the owner does: with the key. If the token had + // opened a chain of its own, this would not find the entry. + const audit = await srv.get('/v2/audit?limit=50', { headers: { 'X-Api-Key': OWNER } }); + assert.strictEqual(audit.status, 200, audit.text); + const found = (audit.json.entries || []).some(e => + e.event === 'inbound' && String(e.hash || '').startsWith(b.hash.slice(0, 16))); + assert.ok(found, `the token upload is missing from the owner audit chain: ${audit.text.slice(0, 400)}`); + did(); +}); + +// ── 5. Expiry ──────────────────────────────────────────────────────────────── + +test('the store expires the token, and the relay refuses it the moment it is gone', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + assert.strictEqual((await srv.get('/v2/check-key', { headers: bearer(token) })).json.valid, true); + + // The TTL redis is really holding, rather than the one the response claimed. + const ttl = await rc.ttl(sessionTokens.tokenKey(token)); + assert.ok(ttl > 800 && ttl <= 900, `the token record must carry the 900 s TTL; redis reports ${ttl}`); + + await rc.del(sessionTokens.tokenKey(token)); + const gone = await srv.get('/v2/check-key', { headers: bearer(token) }); + assert.strictEqual(gone.status, 200, 'check-key is public, so an expired token is simply not a principal'); + assert.strictEqual(gone.json.valid, false, 'an expired token must not authenticate anybody'); + // And on a gated route it is the ordinary 401, the same one an unknown key + // gets: nothing here says "this token expired" to whoever is holding it. + const up = await upload(bearer(token), blob('expired')); + assert.strictEqual(up.status, 401); + assert.deepStrictEqual(up.json, { error: 'Invalid API key', hint: 'X-Api-Key: pgp_...' }); + did(); +}); + +test('a record that outlived its own TTL is refused on the wall clock inside it', async (t) => { + if (!rc) return t.skip('no redis'); + // Redis is what expires a token; this is the second lock, for a record that + // came back from a backup or from a replica that lagged through the expiry. + const token = `pst_${crypto.randomBytes(32).toString('hex')}`; + await rc.set(sessionTokens.tokenKey(token), + JSON.stringify({ key: OWNER, exp: Date.now() - 1000 }), { EX: 300 }); + const r = await upload(bearer(token), blob('stale-record')); + assert.strictEqual(r.status, 401, 'a record past its own exp must not authenticate, TTL or no TTL'); + await rc.del(sessionTokens.tokenKey(token)); + did(); +}); + +// ── 6. Revocation, twice over ──────────────────────────────────────────────── + +test('revoking the key sweeps its live tokens out of the shared store', async (t) => { + if (!rc) return t.skip('no redis'); + const a = (await mint(REVOKE_ME)).json.token; + const b = (await mint(REVOKE_ME)).json.token; + assert.strictEqual((await srv.get('/v2/check-key', { headers: bearer(a) })).json.valid, true); + + const rev = await srv.post('/v2/admin/keys/revoke', { + headers: { 'X-Admin-Token': ADMIN }, body: { key: REVOKE_ME }, + }); + assert.strictEqual(rev.status, 200, rev.text); + + // The sweep is fire-and-forget, so give it a moment to land in redis. + for (let i = 0; i < 40 && await rc.exists(sessionTokens.tokenKey(a)); i += 1) { + await new Promise((r) => setTimeout(r, 25)); + } + assert.strictEqual(await rc.exists(sessionTokens.tokenKey(a)), 0, 'the first token is gone from the store'); + assert.strictEqual(await rc.exists(sessionTokens.tokenKey(b)), 0, 'and so is the second'); + assert.strictEqual((await srv.get('/v2/check-key', { headers: bearer(a) })).json.valid, false); + did(); +}); + +test('and a token whose owner is revoked is refused even when the sweep never ran', async (t) => { + if (!rc) return t.skip('no redis'); + // The belt to the sweep's braces. A sector that was restarting, or a redis + // that was briefly unreachable, can miss the sweep. So the record is put back + // by hand here, exactly as the sweep failing would leave it, and the relay + // must still refuse: a resolved token is looked up in apiKeys like any other + // credential, and a revoked key is not there. + const token = `pst_${crypto.randomBytes(32).toString('hex')}`; + await rc.set(sessionTokens.tokenKey(token), + JSON.stringify({ key: REVOKE_ME, exp: Date.now() + 600_000 }), { EX: 600 }); + const r = await upload(bearer(token), blob('revoked-owner')); + assert.strictEqual(r.status, 401, 'a live record for a revoked key must not authenticate'); + assert.strictEqual((await srv.get('/v2/check-key', { headers: bearer(token) })).json.valid, false); + await rc.del(sessionTokens.tokenKey(token)); + did(); +}); + +// ── 7. A token never overrides a key ───────────────────────────────────────── + +test('an X-Api-Key on the request always wins: a token cannot widen or narrow it', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + + // A request that carries a bad key is that key's request, and stays a 401. + // Reading the Bearer here would let a page smuggle a working credential past + // a caller that thought it was sending its own. + const wrong = await srv.post('/v2/inbound', { + headers: { ...bearer(token), 'X-Api-Key': 'pgp_wrong_key' }, + body: { hash: 'a'.repeat(64), payload: 'AA==' }, + }); + assert.strictEqual(wrong.status, 401, 'the api-key decides; the token must not rescue it'); + + // And with the real key present, the token does not narrow the request + // either: a route outside the token scope stays open to the key. + const audit = await srv.get('/v2/audit', { headers: { ...bearer(token), 'X-Api-Key': OWNER } }); + assert.strictEqual(audit.status, 200, `the scope gate must not fire on a key-authenticated request: ${audit.text}`); + did(); +}); + +test('a malformed or foreign Bearer is simply not a credential', async (t) => { + if (!rc) return t.skip('no redis'); + for (const value of [ + 'Bearer pst_short', + `Bearer pst_${'z'.repeat(64)}`, + `Bearer ${OWNER}`, // the api-key, offered as a Bearer + `Basic pst_${'a'.repeat(64)}`, + 'Bearer', + ]) { + const r = await upload({ Authorization: value }, blob('bad-bearer')); + assert.strictEqual(r.status, 401, `${value} was accepted as a credential`); + } + did(); +}); + +// ── 8. No store ────────────────────────────────────────────────────────────── + +test('FAIL CLOSED: a relay with no store answers 503 to a token, never 401', async (t) => { + if (!rc) return t.skip('no redis'); + // A 401 during an outage tells a sender holding a perfectly good token that + // it is bad, and sends them to sign in again over a store that cannot answer + // that either. 503 with Retry-After is the honest answer, and it is the one + // the page's refresh path is written against. + const noRedis = await boot({ tag: 'sesstok-nostore', users: users(), env: { INTERNAL_AUTH_TOKEN: INTERNAL } }); + t.after(() => noRedis.stop()); + + const token = `pst_${crypto.randomBytes(32).toString('hex')}`; + const used = await noRedis.get('/v2/check-key', { headers: bearer(token) }); + assert.strictEqual(used.status, 503, used.text); + assert.strictEqual(used.json.error, 'redis_unavailable'); + assert.strictEqual(used.headers['retry-after'], '5'); + + // And no token is handed out that no relay could ever check. + const minted = await noRedis.post('/v2/session-token', { + headers: { 'X-Internal-Auth': INTERNAL, 'X-Api-Key': OWNER }, + }); + assert.strictEqual(minted.status, 503, minted.text); + assert.strictEqual(minted.json.error, 'redis_unavailable'); + did(); +}); diff --git a/relay/test/session-token.test.js b/relay/test/session-token.test.js new file mode 100644 index 00000000..14ba1010 --- /dev/null +++ b/relay/test/session-token.test.js @@ -0,0 +1,329 @@ +'use strict'; +// The decision layer of ParaSend session tokens: relay/lib/session-token.js. +// +// This suite is the one that needs nothing -- no relay, no redis, no native +// binding -- so it runs in the unit job and gates the rules a reviewer would +// otherwise have to read the route for. The HTTP behaviour of the same rules, +// against a real relay and a real redis, is route-session-token.test.js. +// +// What is pinned here, and why each line earns its place: +// * the scope allowlist, from both ends: the five routes a transfer walks are +// open, and the routes the review named as the danger (/v2/keys, +// /v2/user/*, /v2/outbound, /v2/admin/*, minting another token) are shut. +// The closed half is asserted by name, because an allowlist that is only +// tested from the open side passes just as well when it is `() => true`; +// * the token shape, so a malformed Authorization header never reaches the +// store; +// * the Bearer parse, including the forms that are NOT a token; +// * mint/resolve/revoke against a fake store, including the TTL that is +// written and the expiry that is honoured even when the store forgot it; +// * a store that is absent or broken THROWS rather than returning null, +// which is what makes the route answer 503 instead of 401. +// +// Verified by sabotage: +// * make scopeAllows return true and the six closed-route cases go red; +// * drop the OPTIONS branch and the preflight case goes red; +// * loosen TOKEN_RE to /^pst_/ and the shape case goes red; +// * return null instead of throwing on a missing client and the fail-closed +// case goes red, which is the exact bug that would turn an outage into a +// silent 401 storm; +// * drop the `exp` check in resolve and the "store forgot the TTL" case goes +// red. +// Run: node --test relay/test/session-token.test.js + +const { test, after } = require('node:test'); +const assert = require('assert'); +const st = require('../lib/session-token'); +const { summary } = require('./_requires'); + +let checks = 0; +const did = () => { checks++; }; +after(() => summary('session-token', checks)); + +// A redis stand-in with the four commands this module uses. TTLs are recorded +// rather than slept through, and `fail` turns the whole thing into the outage. +function fakeRedis() { + const store = new Map(); // key -> string + const ttls = new Map(); // key -> seconds + const sets = new Map(); // key -> Set + const api = { + fail: null, + calls: [], + _guard(op) { api.calls.push(op); if (api.fail) throw api.fail; }, + async get(k) { api._guard(['get', k]); return store.has(k) ? store.get(k) : null; }, + async set(k, v, opts) { api._guard(['set', k]); store.set(k, v); if (opts && opts.EX) ttls.set(k, opts.EX); }, + async sAdd(k, v) { api._guard(['sAdd', k]); if (!sets.has(k)) sets.set(k, new Set()); sets.get(k).add(v); }, + async sMembers(k) { api._guard(['sMembers', k]); return [...(sets.get(k) || [])]; }, + async expire(k, s) { api._guard(['expire', k]); ttls.set(k, s); }, + async del(k) { + api._guard(['del', k]); + const keys = Array.isArray(k) ? k : [k]; + let n = 0; + for (const one of keys) { if (store.delete(one)) n++; sets.delete(one); ttls.delete(one); } + return n; + }, + store, ttls, sets, + }; + return api; +} + +// ── 1. The scope allowlist, from the open side ─────────────────────────────── + +test('the five routes a ParaSend transfer walks are the routes the token opens', () => { + const walk = [ + // Sector discovery races four hosts and reads valid/plan. + ['GET', '/v2/check-key'], + // The one-time ticket for the signalling socket. + ['POST', '/v2/ws-ticket'], + // The sender publishes its half of the handshake and polls the receiver's. + ['POST', '/v2/pubkey'], + ['GET', '/v2/pubkey/inv_0123456789abcdef0123456789abcdef'], + // And the upload. + ['POST', '/v2/inbound'], + ]; + for (const [method, path] of walk) { + assert.strictEqual(st.scopeAllows(method, path), true, + `${method} ${path} is on the path a sender walks; a token that cannot do it is not a usable credential`); + } + did(); +}); + +test('a preflight is never refused by scope: it carries no credential to judge', () => { + assert.strictEqual(st.scopeAllows('OPTIONS', '/v2/keys'), true); + assert.strictEqual(st.scopeAllows('options', '/v2/user/signing-key'), true); + did(); +}); + +// ── 2. The closed side, which is the whole point ───────────────────────────── + +test('THE POINT: everything the security review named is shut, by name', () => { + const shut = [ + // The credential surface. A token minted to send a file must never be able + // to list, mint or reveal a key. + ['GET', '/v2/keys'], + ['POST', '/v2/keys'], + // The account's own session surface. Enrolling a signing key with a token + // stolen from a page would be the whole ParaSign trust model, gone. + ['POST', '/v2/user/signing-key'], + ['GET', '/v2/user/signing-key'], + ['DELETE', '/v2/user/signing-key'], + ['POST', '/v2/user/setup-totp'], + ['POST', '/v2/user/envelopes'], + ['GET', '/v2/user/history'], + // Downloading is the receiver's half and needs no account credential. + ['GET', '/v2/outbound/' + 'a'.repeat(64)], + // The account's history. + ['GET', '/v2/audit'], + // Admin, under any credential but ADMIN_TOKEN. + ['GET', '/v2/admin/keys'], + ['POST', '/v2/admin/keys/revoke'], + // A token may not roll its own fifteen minutes forward. + ['POST', '/v2/session-token'], + // ParaSign, which is a different product on the same account. + ['POST', '/v2/envelopes'], + ['POST', '/v1/envelopes'], + ]; + for (const [method, path] of shut) { + assert.strictEqual(st.scopeAllows(method, path), false, + `${method} ${path} is reachable with a session token; the allowlist is the only thing that makes this credential narrower than an API key`); + } + did(); +}); + +test('the method is part of the rule, not decoration', () => { + // POST /v2/pubkey publishes; GET /v2/pubkey is the RELAY's identity key and + // is not on the sender's path. The one that is on the path is the per-device + // read below it. + assert.strictEqual(st.scopeAllows('GET', '/v2/pubkey'), false, + 'GET /v2/pubkey is the relay identity route, not a step in a transfer'); + assert.strictEqual(st.scopeAllows('DELETE', '/v2/inbound'), false); + assert.strictEqual(st.scopeAllows('GET', '/v2/inbound'), false); + assert.strictEqual(st.scopeAllows('GET', '/v2/ws-ticket'), false); + did(); +}); + +test('the per-device pubkey read is exactly one path segment deep', () => { + assert.strictEqual(st.scopeAllows('GET', '/v2/pubkey/inv_abc'), true); + // Not a prefix match: a deeper path is a different route and gets no ride. + assert.strictEqual(st.scopeAllows('GET', '/v2/pubkey/inv_abc/secret'), false); + assert.strictEqual(st.scopeAllows('GET', '/v2/pubkey/'), false, 'an empty device id is not a route'); + // POST under the same prefix is /v2/pubkey/verify, and it is not in scope. + assert.strictEqual(st.scopeAllows('POST', '/v2/pubkey/verify'), false); + did(); +}); + +// ── 3. The token shape ─────────────────────────────────────────────────────── + +test('only a pst_ token of the exact minted shape is ever looked up', () => { + assert.strictEqual(st.isSessionToken('pst_' + 'a'.repeat(64)), true); + const no = [ + '', null, undefined, 42, {}, + 'pgp_a_real_api_key_would_be_a_disaster_here', + 'pst_', + 'pst_' + 'a'.repeat(63), // one short + 'pst_' + 'a'.repeat(65), // one long + 'pst_' + 'A'.repeat(64), // uppercase is not what randomBytes.hex makes + 'pst_' + 'g'.repeat(64), // not hex + ' pst_' + 'a'.repeat(64), // leading space + 'pst_' + 'a'.repeat(64) + '\n', + ]; + for (const v of no) { + assert.strictEqual(st.isSessionToken(v), false, `${JSON.stringify(v)} must not be treated as a token`); + } + did(); +}); + +test('the Bearer parse takes the token and nothing else', () => { + const tok = 'pst_' + 'b'.repeat(64); + assert.strictEqual(st.bearerToken(`Bearer ${tok}`), tok); + assert.strictEqual(st.bearerToken(`bearer ${tok}`), tok, 'the scheme is case-insensitive per RFC 7235'); + assert.strictEqual(st.bearerToken(` Bearer ${tok} `), tok, 'surrounding whitespace is not part of the credential'); + assert.strictEqual(st.bearerToken(`Bearer\t${tok}`), tok); + for (const bad of ['', null, undefined, 42, tok, `Basic ${tok}`, `Bearer ${tok} extra`, 'Bearer']) { + assert.strictEqual(st.bearerToken(bad), '', `${JSON.stringify(bad)} is not a Bearer credential`); + } + did(); +}); + +// ── 4. Mint ────────────────────────────────────────────────────────────────── + +test('mint writes one record under the token, with the TTL it promises', async () => { + const r = fakeRedis(); + const out = await st.mint(r, 'pgp_owner_demo', 1_000_000); + + assert.ok(st.isSessionToken(out.token), 'a minted token must satisfy the shape the resolver demands'); + assert.strictEqual(out.expires_in_s, st.TTL_S); + assert.strictEqual(st.TTL_S, 900, 'fifteen minutes is the number SECURITY.md and /privacy both state'); + assert.strictEqual(out.expires_ms, 1_000_000 + 900_000); + + const rk = st.tokenKey(out.token); + assert.deepStrictEqual(JSON.parse(r.store.get(rk)), { key: 'pgp_owner_demo', exp: 1_900_000 }); + assert.strictEqual(r.ttls.get(rk), 900, 'redis must be the thing that expires the token, not a sweeper'); + did(); +}); + +test('two mints are two different tokens, and the owner key is never a redis key name', async () => { + const r = fakeRedis(); + const a = await st.mint(r, 'pgp_owner_demo'); + const b = await st.mint(r, 'pgp_owner_demo'); + assert.notStrictEqual(a.token, b.token, 'a token that repeats is a token an attacker can wait for'); + + // Key NAMES show up in SCAN output, slowlog entries and keyspace listings. An + // api-key in a key name is an api-key in every one of those. + for (const name of r.store.keys()) assert.ok(!name.includes('pgp_owner_demo'), `redis key name leaks the api-key: ${name}`); + for (const name of r.sets.keys()) assert.ok(!name.includes('pgp_owner_demo'), `redis set name leaks the api-key: ${name}`); + assert.match(st.ownerKey('pgp_owner_demo'), /^paramant:pst:owner:[0-9a-f]{64}$/); + did(); +}); + +test('the sweep index holds both tokens and outlives neither by much', async () => { + const r = fakeRedis(); + const a = await st.mint(r, 'pgp_owner_demo'); + const b = await st.mint(r, 'pgp_owner_demo'); + const idx = st.ownerKey('pgp_owner_demo'); + assert.deepStrictEqual([...r.sets.get(idx)].sort(), [a.token, b.token].sort()); + assert.strictEqual(r.ttls.get(idx), st.TTL_S + 60, + 'the index must expire on its own; a set that lives forever is a slow leak with an api-key hash in the name'); + did(); +}); + +// ── 5. Resolve ─────────────────────────────────────────────────────────────── + +test('a minted token resolves to the api-key it was minted for', async () => { + const r = fakeRedis(); + const { token, expires_ms } = await st.mint(r, 'pgp_owner_demo', 5_000); + assert.deepStrictEqual(await st.resolve(r, token, 6_000), { key: 'pgp_owner_demo', expires_ms }); + did(); +}); + +test('every refusal looks the same: null, with no way to tell them apart', async () => { + const r = fakeRedis(); + const { token } = await st.mint(r, 'pgp_owner_demo', 5_000); + + // Never minted. + assert.strictEqual(await st.resolve(r, 'pst_' + 'c'.repeat(64)), null); + // Not even a token. + assert.strictEqual(await st.resolve(r, 'pgp_a_real_key'), null); + assert.strictEqual(await st.resolve(r, ''), null); + // Revoked out from under it. + await r.del(st.tokenKey(token)); + assert.strictEqual(await st.resolve(r, token), null); + did(); +}); + +test('an expired record is refused even when the store still holds it', async () => { + // Redis is what expires a token. This is the second lock: a record restored + // from a backup, or served by a replica that lagged through the expiry, still + // carries the wall-clock expiry it was minted with, and that is checked. + const r = fakeRedis(); + const { token, expires_ms } = await st.mint(r, 'pgp_owner_demo', 5_000); + assert.ok(await st.resolve(r, token, expires_ms), 'a token AT its expiry ms is still live'); + assert.strictEqual(await st.resolve(r, token, expires_ms + 1), null, 'one millisecond past it, it is not'); + did(); +}); + +test('a record that is not the shape this module writes is refused, not trusted', async () => { + const r = fakeRedis(); + const tok = 'pst_' + 'd'.repeat(64); + for (const junk of ['not json', 'null', '[]', '{}', '{"key":""}', '{"key":123}']) { + await r.set(st.tokenKey(tok), junk); + assert.strictEqual(await st.resolve(r, tok), null, `a record of ${junk} must not yield a principal`); + } + did(); +}); + +// ── 6. Fail closed ─────────────────────────────────────────────────────────── + +test('FAIL CLOSED: no store is a throw, never a quiet null', async () => { + // The distinction is the whole difference between a 503 and a 401. A null + // here would tell a sender holding a perfectly good token that it is bad, and + // send them back to sign in, during an outage where signing in cannot work. + await assert.rejects(() => st.resolve(null, 'pst_' + 'e'.repeat(64)), /no redis client/); + await assert.rejects(() => st.mint(null, 'pgp_owner_demo'), /no redis client/); + did(); +}); + +test('a broken store propagates, so the route can answer 503 instead of 401', async () => { + const r = fakeRedis(); + const { token } = await st.mint(r, 'pgp_owner_demo'); + r.fail = new Error('redis command timed out after 1000ms'); + await assert.rejects(() => st.resolve(r, token), /timed out/); + await assert.rejects(() => st.mint(r, 'pgp_owner_demo'), /timed out/); + did(); +}); + +test('mint refuses to hand out a token with no owner on it', async () => { + const r = fakeRedis(); + for (const bad of ['', null, undefined, 42]) { + await assert.rejects(() => st.mint(r, bad), /no owner key/); + } + did(); +}); + +// ── 7. Revoke ──────────────────────────────────────────────────────────────── + +test('revoking a key takes every one of its live tokens with it', async () => { + const r = fakeRedis(); + const a = await st.mint(r, 'pgp_owner_demo'); + const b = await st.mint(r, 'pgp_owner_demo'); + const other = await st.mint(r, 'pgp_owner_other'); + + const n = await st.revokeForKey(r, 'pgp_owner_demo'); + assert.strictEqual(n, 2, 'both tokens are reported swept'); + assert.strictEqual(await st.resolve(r, a.token), null); + assert.strictEqual(await st.resolve(r, b.token), null); + assert.ok(await st.resolve(r, other.token), 'another account is untouched: revocation is per key, not per store'); + assert.strictEqual(r.sets.has(st.ownerKey('pgp_owner_demo')), false, 'the index goes too, or it grows forever'); + did(); +}); + +test('revoking a key with no tokens is a no-op, not an error', async () => { + const r = fakeRedis(); + assert.strictEqual(await st.revokeForKey(r, 'pgp_owner_never_used'), 0); + // And a caller with no store at all (a relay without REDIS_URL) gets 0 rather + // than a throw: the revocation itself has already happened in users.json, and + // a relay with no store has no tokens to sweep. + assert.strictEqual(await st.revokeForKey(null, 'pgp_owner_demo'), 0); + assert.strictEqual(await st.revokeForKey(fakeRedis(), ''), 0); + did(); +}); From 417a20f5712c09a9d16e281c90a96936da6f5fa2 Mon Sep 17 00:00:00 2001 From: Apolloccrypt Date: Thu, 3 Sep 2026 12:08:39 +0200 Subject: [PATCH 2/6] ParaSend: the account key stays on the server, the browser gets a session 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. --- SECURITY.md | 75 ++++++++ admin/server.js | 49 +++++ admin/test/_admin-server.js | 13 +- admin/test/parasend-token.test.js | 237 ++++++++++++++++++++++++ frontend/js/parashare.page.js | 274 +++++++++++++++++++--------- frontend/parashare.html | 12 +- frontend/privacy.html | 2 + tests/parasend-session-key.test.mjs | 259 +++++++++++++++++++------- tests/site-claims.test.mjs | 62 ++++++- 9 files changed, 829 insertions(+), 154 deletions(-) create mode 100644 admin/test/parasend-token.test.js diff --git a/SECURITY.md b/SECURITY.md index 3afcd1a6..1bdd70ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -117,6 +117,81 @@ 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. + +#### 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. It is not what +`/parashare` uses, and the page's "Use a key by hand" way out is meant for a +self-hosted deployment with no admin panel, but the route is reachable from any +signed-in browser and answers with the raw key. A script with fifteen minutes +and a session cookie can call it. Closing that means deciding what the account +page and the self-host flow do instead, which is a separate change; until then, +the ceiling above is the ceiling for a user who has visited a page that can +reveal, not for ParaSend alone. + +--- + ### 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 d4e3858e..d685d48d 100644 --- a/admin/server.js +++ b/admin/server.js @@ -2469,6 +2469,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/frontend/js/parashare.page.js b/frontend/js/parashare.page.js index bc4f9083..993ab230 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,101 @@ 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 || {}; + if (sessionAuth && sessionAuthExp && Date.now() > sessionAuthExp - TOKEN_MARGIN_MS) { + 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) { + 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 +405,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 +430,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 +444,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 +511,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 +544,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 +560,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 +677,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 +711,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 +835,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