From cc251313218ddbef9991061d6f3dce5c3f0de817 Mon Sep 17 00:00:00 2001 From: Apolloccrypt Date: Thu, 3 Sep 2026 14:49:41 +0200 Subject: [PATCH] /account, /pricing and /dashboard stop putting the account key in the browser PR #401 gave /parashare a pst_ session token so the page never has to hold a pgp_ api-key. Three pages were left behind: /pricing fetched the key from GET /api/user/account/key and sent it as X-Api-Key to start a checkout, /dashboard did the same for the history and audit-export reads, and /account rendered the masked key on load whether or not anybody asked to see it. An api-key has no expiry and no scope, so each of those visits put a full data-plane credential, or the shape of one, into a tab opened to read a price or check a quota. A SECOND PURPOSE, NOT A WIDER SCOPE. relay/lib/session-token.js now mints a token FOR a purpose, and the purpose picks the allowlist it is judged against. SCOPE is unchanged: the five ParaSend transfer routes. APP_SCOPE is new and holds three, each because one page needed exactly it: POST /v2/billing/checkout /pricing, pressing a price button creates the Mollie payment; it moves no money itself and reveals nothing about the account. GET /v2/user/history /dashboard, a read-only projection over the account's own audit chain. Named on its own path, never as a prefix, so the rest of /v2/user/* (signing keys, TOTP) stays shut. GET /v2/parasign/audit-export /dashboard, read-only over the same chain and tier-gated at Business+ by the route itself. The two lists are disjoint. A token minted on /parashare is 403 on all three app routes and a token minted on /pricing is 403 on all five transfer routes. Merging them into one flat allowlist would have widened the ParaSend token by three routes to give three other pages a credential they needed, which is how a narrow credential quietly becomes an api-key again. The purpose is fixed by the admin route, never named by the caller: POST /api/user/parasend/token asks for parasend, the new POST /api/user/app/token asks for app, and both ignore their request body. A purpose the relay does not know is refused at the mint with 400 unknown_purpose rather than folded onto a default, and a stored record carrying an unknown purpose authenticates nobody. A record with no purpose field predates this change and is a ParaSend token, so live tokens survive the deploy. WHAT THE PAGES DO NOW. /account fetches the key only when the existing "Advanced account key" fold is opened, and shows it masked there; Show and Copy reuse what that fetch returned. Nothing is fetched or rendered on load. /pricing js/app-session-token.js mints an app token on the first click and sends Authorization: Bearer pst_. Nothing is minted on load. /dashboard the same for the history view and the audit export, and the Operations card no longer prints a key in any form. The overview endpoint stopped sending key_masked with it. TESTS. tests/app-pages-no-api-key.test.mjs drives real Chromium over the three pages and fails if any of them asks for the key on load or renders anything shaped like one, pins that opening the fold is what asks and asks exactly once, and presses a real price button to check the checkout call carries a pst_ bearer and no X-Api-Key header. The relay unit and route suites gained the two purposes from both ends, including the wall between them. site-claims keeps /privacy pinned to the code in both directions: the holder list is one page now, and the ex-holders are asserted to stay moved. /privacy, SECURITY.md and docs/api.md are rewritten with the code rather than after it. The reveal route still exists and still answers a signed-in browser, because /account is the page whose job is to show you your key and a self-hoster needs it; what changed is that the key is no longer lying in a variable on two pages nobody opened for that reason. --- SECURITY.md | 82 ++++++++---- admin/server.js | 38 +++++- admin/test/parasend-token.test.js | 57 ++++++++ docs/api.md | 40 ++++-- frontend/account.html | 4 +- frontend/dashboard.html | 2 +- frontend/js/account.inline1.js | 88 ++++++++++-- frontend/js/app-session-token.js | 108 +++++++++++++++ frontend/js/dashboard-history.js | 67 +++++----- frontend/js/dashboard.js | 8 +- frontend/js/pricing-billing.js | 52 +++++--- frontend/pricing.html | 3 +- frontend/privacy.html | 4 +- relay/lib/session-token.js | 89 ++++++++++++- relay/relay.js | 36 ++++- relay/test/route-session-token.test.js | 74 ++++++++++- relay/test/session-token.test.js | 127 +++++++++++++++++- tests/app-pages-no-api-key.test.mjs | 177 +++++++++++++++++++++++++ tests/site-claims.test.mjs | 29 +++- 19 files changed, 951 insertions(+), 134 deletions(-) create mode 100644 frontend/js/app-session-token.js create mode 100644 tests/app-pages-no-api-key.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 6ad8aaed..4950a0b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -211,30 +211,64 @@ That is a real reduction and it is not a fix for cross-site scripting. The CSP on the site and the escaping in the pages remain the thing that stops a script running in the first place; this only bounds what one gets if it does. -#### What is still open - -The `GET /api/user/account/key` reveal route still exists, and ParaSend was not -its only caller. Three pages still fetch the raw key into the browser and use it -as a relay credential: - -| Page | File | What it does with the key | -|------|------|---------------------------| -| `/account` | `frontend/js/account.inline1.js` | reveals it on the screen, deliberately | -| `/pricing` | `frontend/js/pricing-billing.js` | `X-Api-Key` on `POST /v2/billing/checkout` | -| `/dashboard` | `frontend/js/dashboard-history.js` | `X-Api-Key` on the usage and history reads | - -So the honest statement of the ceiling is this: on `/parashare` the key is gone, -and on a browser that has loaded any of those three pages it is not. The route -is reachable from any signed-in browser and answers with the raw key, so a -script with a session cookie can also simply ask for it. - -Closing that is the next change, and it is not one line: `/pricing` and -`/dashboard` need scoped credentials of their own (or server-side proxies, which -is what `/api/user/documents` already does), and `/account` has to keep a way to -show a key that a self-hoster genuinely needs. Recorded here rather than fixed, -because a partial fix that removed the route would break three pages, and one -that left it while claiming the key is out of the browser would be the same kind -of untruth this section exists to correct. +#### What was still open, and what closed it + +The `GET /api/user/account/key` reveal route existed and ParaSend was not its +only caller: `/account`, `/pricing` and `/dashboard` each fetched the raw key +into the browser and used it as a relay credential. The honest statement of the +ceiling at the time was that on `/parashare` the key was gone, and on a browser +that had loaded any of those three pages it was not. + +That is now closed, and closing it needed a second purpose rather than a wider +scope. + +**A token is minted FOR a purpose, and the purpose picks the allowlist.** +`relay/lib/session-token.js` holds two lists. `SCOPE` is the ParaSend one and is +unchanged: the five transfer routes above. `APP_SCOPE` is the new one and holds +three routes, each because one page needed exactly it: + +| Route | Page | Why | +|-------|------|-----| +| `POST /v2/billing/checkout` | `/pricing` | pressing a price button creates the Mollie payment | +| `GET /v2/user/history` | `/dashboard` | the account's own send/envelope history, read-only | +| `GET /v2/parasign/audit-export` | `/dashboard` | the account's own signing audit, read-only, Business+ | + +The two lists are **disjoint**. A token minted on `/parashare` is `403` on all +three app routes, and a token minted on `/pricing` is `403` on all five transfer +routes. Merging them into one flat allowlist would have widened the ParaSend +token by three routes to give three other pages a credential they needed, which +is how a narrow credential quietly becomes an api-key again. `/v2/user/history` +is named on its own path, never as a prefix, so the rest of `/v2/user/*` (the +signing-key and TOTP surface) stays shut under both purposes, as do `/v2/keys`, +`/v2/outbound`, `/v2/audit`, `/v2/admin/*` and a second mint. + +The purpose is chosen by the ADMIN ROUTE, never by the caller: +`POST /api/user/parasend/token` asks for `parasend` and +`POST /api/user/app/token` asks for `app`, both ignore their request body, and a +purpose the relay does not recognise is refused at the mint with `400 +unknown_purpose` rather than folded onto a default. A stored record with no +purpose field predates this change and is a ParaSend token; a record carrying a +purpose the running build does not know authenticates nobody at all. + +**What each page does now.** + +| Page | File | Credential | +|------|------|------------| +| `/account` | `frontend/js/account.inline1.js` | the reveal route, and only when the "Advanced account key" fold is opened. Nothing is fetched or rendered on load | +| `/pricing` | `frontend/js/pricing-billing.js` | `Authorization: Bearer pst_...`, purpose `app`, minted on the first click | +| `/dashboard` | `frontend/js/dashboard-history.js` | the same, minted on the click that needs it. `/dashboard` no longer prints a key in any form, masked included | + +`tests/app-pages-no-api-key.test.mjs` drives real Chromium over the three pages +and fails if any of them asks for the key on load, or renders anything shaped +like one. + +**What is left, stated plainly.** The reveal route still exists and still +answers any signed-in browser with the raw key, because `/account` is the page +whose job is to show it to you and a self-hoster genuinely needs it. So a script +that runs on paramant.app with a session cookie can still ask for the key +directly. What changed is that it no longer finds one lying in a variable on a +page nobody opened for that reason, and that the two pages which used to put it +there now run on a credential that expires and opens three routes. --- diff --git a/admin/server.js b/admin/server.js index 0a055e35..0822620b 100644 --- a/admin/server.js +++ b/admin/server.js @@ -2531,7 +2531,12 @@ function maskIp(ip) { // 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) => { +// +// The purpose is chosen HERE, by the route, never by the caller. A browser that +// could name its own purpose could ask /parashare's endpoint for an `app` token +// and start a checkout with it. Two routes, two fixed words, and the body the +// browser sends is still ignored. +async function mintSessionToken(req, res, purpose, label) { const key = proxyApiKey(req.userSession); if (!key) return res.status(403).json({ error: "no_account_key" }); try { @@ -2542,7 +2547,7 @@ api.post("/user/parasend/token", authUser, async (req, res) => { "X-Internal-Auth": INTERNAL_TOKEN, "X-Api-Key": key, }, - body: "{}", + body: JSON.stringify({ purpose }), signal: AbortSignal.timeout(10000), }); const body = await rr.json().catch(() => ({ error: "bad_relay_response" })); @@ -2556,10 +2561,29 @@ api.post("/user/parasend/token", authUser, async (req, res) => { } return res.json({ token: body.token, expires_in_s: body.expires_in_s }); } catch (err) { - console.error("[user/parasend/token]", err.message); + console.error(label, err.message); return res.status(502).json({ error: "relay_unreachable" }); } -}); +} + +api.post("/user/parasend/token", authUser, (req, res) => + mintSessionToken(req, res, "parasend", "[user/parasend/token]")); + +// POST /api/user/app/token +// +// The same trade for the signed-in app pages. /pricing needs to start a +// checkout and /dashboard needs to read the account's own history and audit +// export; all three used to fetch GET /api/user/account/key and authenticate to +// the relay with the pgp_ key itself, which put a credential with no expiry and +// no scope into any tab that visited them. +// +// A DIFFERENT token from the ParaSend one, not a wider one. The relay judges an +// `app` token against APP_SCOPE (checkout, history, audit-export) and a +// `parasend` token against SCOPE (the five transfer routes); neither list +// contains the other, so this route gives /pricing and /dashboard what they +// need without giving /parashare anything it did not already have. +api.post("/user/app/token", authUser, (req, res) => + mintSessionToken(req, res, "app", "[user/app/token]")); // GET /api/user/account/key api.get("/user/account/key", authUser, async (req, res) => { @@ -2582,7 +2606,11 @@ api.get("/user/dashboard/overview", authUser, async (req, res) => { try { const u = await findUserByEmail(req.userSession.email); if (u && u.plan) plan = u.plan; } catch {} try { const snap = await buildSnapshot({ redis, getAuditEvents, plan }, req.userSession); - const data = { plan: snap.plan, key_masked: snap.key_masked, quota: snap.quota, audit: snap.audit }; + // key_masked is deliberately NOT passed on. /dashboard no longer prints a + // key in any form, and a payload that still carries one is a payload that + // will be printed again by the next person who reads it and assumes it is + // there to be used. The account key lives on /account, behind its fold. + const data = { plan: snap.plan, quota: snap.quota, audit: snap.audit }; _ovCache.set(uid, { at: Date.now(), data }); if (_ovCache.size > 500) _ovCache.clear(); res.json(data); diff --git a/admin/test/parasend-token.test.js b/admin/test/parasend-token.test.js index 5affd6e3..ce19fbc0 100644 --- a/admin/test/parasend-token.test.js +++ b/admin/test/parasend-token.test.js @@ -107,6 +107,7 @@ after(async () => { }); const mint = (headers = {}) => srv.post('/api/user/parasend/token', { headers }); +const mintApp = (headers = {}) => srv.post('/api/user/app/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) => { @@ -172,6 +173,62 @@ test('a body cannot name an account: the session decides, always', async (t) => did(); }); +// ── The second route: the signed-in app pages ──────────────────────────────── +// /pricing and /dashboard stopped fetching the account's pgp_ key and mint a +// token of their own. It is a SECOND route rather than a parameter on the first +// one, because a purpose the browser could name is a purpose the browser could +// change: a script on /parashare would ask for an `app` token and start a +// checkout with it. The word is fixed by the route, and the body is still +// ignored. + +test('the app route mints with purpose app, and the browser never chooses that word', async (t) => { + if (!redis) return t.skip('no redis'); + relay.state.calls.length = 0; + const r = await mintApp(await session()); + assert.strictEqual(r.status, 200, r.text); + assert.strictEqual(r.json.token, MINTED); + assert.deepStrictEqual(Object.keys(r.json).sort(), ['expires_in_s', 'token'], + 'the app route answers exactly what the ParaSend one does: a token and its life, nothing else'); + assert.ok(!r.text.includes('pgp_'), 'nothing shaped like an api-key may leave this route either'); + + const call = mintCalls().at(-1); + assert.ok(call, 'the admin must actually ask the relay'); + assert.strictEqual(call.headers['x-api-key'], ACCOUNT_KEY); + assert.strictEqual(call.headers['x-internal-auth'], INTERNAL); + assert.deepStrictEqual(call.body, { purpose: 'app' }, + 'the purpose the relay is asked for must be the route\'s own word'); + did(); +}); + +test('the ParaSend route still asks for the ParaSend purpose, and a body cannot change either', async (t) => { + if (!redis) return t.skip('no redis'); + relay.state.calls.length = 0; + await mint(await session()); + assert.deepStrictEqual(mintCalls().at(-1).body, { purpose: 'parasend' }); + + // A browser trying to name the other purpose on either route gets its own + // route's word, not the one it asked for. This is the whole reason the + // purpose is not a parameter. + for (const [path, expected] of [['/api/user/parasend/token', 'parasend'], ['/api/user/app/token', 'app']]) { + relay.state.calls.length = 0; + const r = await srv.post(path, { headers: await session(), body: { purpose: 'admin' } }); + assert.strictEqual(r.status, 200, r.text); + assert.deepStrictEqual(mintCalls().at(-1).body, { purpose: expected }, + `${path} passed on a purpose the caller supplied`); + } + did(); +}); + +test('the app route needs a session too: no cookie, no token, no mint', async (t) => { + if (!redis) return t.skip('no redis'); + const before = mintCalls().length; + const r = await mintApp(); + assert.strictEqual(r.status, 401); + assert.deepStrictEqual(r.json, { error: 'unauthenticated' }); + assert.strictEqual(mintCalls().length, before, 'an unauthenticated caller must not cause a mint'); + 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]) { diff --git a/docs/api.md b/docs/api.md index 80425ba3..5d034532 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,19 +36,28 @@ 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 +> **Browser 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: +> minted by the admin panel on behalf of a logged-in user, 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 +> - **Purpose, and two allowlists.** A token is minted FOR a purpose, and the +> purpose picks the list it is judged against. `parasend` +> (`POST /api/user/parasend/token`, used by `/parashare`) 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. +> `GET /v2/pubkey/:device` and `POST /v2/inbound`. `app` +> (`POST /api/user/app/token`, used by `/pricing` and `/dashboard`) opens +> `POST /v2/billing/checkout`, `GET /v2/user/history` and +> `GET /v2/parasign/audit-export`. The two lists are disjoint: neither purpose +> can do the other's work. The purpose is fixed by the admin route, not by the +> caller, and an unknown one is refused at the mint with `400 unknown_purpose`. +> - **Scope.** The allowlist is checked above every route handler. Any path not +> on the list for that purpose answers `403 session_token_out_of_scope`, +> including the rest of `/v2/user/*`, `/v2/keys`, `/v2/outbound/:hash`, +> `/v2/audit`, `/v2/admin/*`, the ParaSign envelope routes, and a second +> `POST /v2/session-token`: no token mints another, whatever it was minted +> for. > - **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, @@ -505,8 +514,15 @@ curl -X POST https://health.paramant.app/v2/session-token \ | 429 | The account already holds 20 live tokens. `Retry-After: 60`. | | 503 | The relay store is unreachable, so no checkable token can be issued. | -The browser-facing half of this is `POST /api/user/parasend/token` on the admin -panel (session cookie, no body, returns only `token` and `expires_in_s`). +The browser-facing half of this is two routes on the admin panel, both session +cookie, both ignoring their request body, both returning only `token` and +`expires_in_s`: `POST /api/user/parasend/token` mints purpose `parasend` and +`POST /api/user/app/token` mints purpose `app`. The purpose is a property of the +route, so a page cannot ask for the other one's authority. + +`POST /v2/session-token` itself takes an optional body `{"purpose": "parasend" | +"app"}`. An absent purpose means `parasend`, so a caller written before purposes +existed is unchanged; an unrecognised one is `400 unknown_purpose`. --- diff --git a/frontend/account.html b/frontend/account.html index 8a49b46b..2ed7ec29 100644 --- a/frontend/account.html +++ b/frontend/account.html @@ -202,7 +202,7 @@

Account settings

plan -
+
Advanced account keyFor legacy SDK, script and IoT access. Normal document use does not need this.
@@ -404,7 +404,7 @@

Deactivate account.

- + diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 49686de7..f4cbefc8 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -661,6 +661,6 @@

Finish setting up your account

- + diff --git a/frontend/js/account.inline1.js b/frontend/js/account.inline1.js index f80fc864..3a3a9fd8 100644 --- a/frontend/js/account.inline1.js +++ b/frontend/js/account.inline1.js @@ -94,7 +94,11 @@ } const data = await res.json(); document.getElementById('account-email').textContent = data.email; - document.getElementById('api-key').textContent = data.api_key_masked; + // The api-key is NOT rendered here, masked or otherwise. See the + // "Advanced account key" block further down: on this page the key is a + // legacy escape hatch behind a fold, and a page that prints it on load + // fetches a credential for every visitor who only came to check what they + // pay. data.api_key_masked is left unread on purpose. document.getElementById('plan').textContent = planName(data, data.plan); var planChip = document.getElementById('plan-chip'); if (planChip) planChip.textContent = planName(data, data.plan); @@ -155,29 +159,93 @@ document.body.removeChild(ta); return ok; } + // ── The account key, behind the fold and only behind the fold ────────────── + // + // /account is the ONE page that may still put the pgp_ key in a browser: it + // is the page whose job is to show it to you, for the SDK, a script or an IoT + // device. Everything else on the site now authenticates to the relay with a + // short-lived scoped pst_ token (relay/lib/session-token.js), and /pricing and + // /dashboard stopped fetching this key entirely. + // + // So the fetch is tied to the "Advanced account key" fold, not to page load. + // A visitor who opens /account to read their plan, their sessions or their + // backup codes never causes a request to /api/user/account/key at all, and no + // key, masked or whole, is in the DOM for them. Opening the fold is the + // deliberate act that asks for it; from then on it is in this closure for the + // life of the tab, which is what Copy and Show need and no longer than they + // need it. tests/app-pages-no-api-key.test.mjs pins the load half of that. + // + // The masked form is computed HERE from the fetched key rather than read from + // data.api_key_masked, because reading that field would mean the account + // payload still had to carry a key shape to the page that promises not to + // hold one until asked. Same shape as the server's mask (admin/server.js): + // first eight, ellipsis, last four. + var _keyPromise = null; + function accountKey() { + if (_keyPromise) return _keyPromise; + _keyPromise = fetch('/api/user/account/key', { + credentials: 'include', headers: { Accept: 'application/json' }, cache: 'no-store' + }).then(function(res) { + if (!res.ok) throw new Error('key_http_' + res.status); + return res.json(); + }).then(function(data) { + if (!data || !data.api_key) throw new Error('key_unavailable'); + return data.api_key; + }).catch(function(err) { + // A failed reveal must not poison the fold: the next Copy or Show tries + // again rather than replaying the error forever. + _keyPromise = null; + throw err; + }); + return _keyPromise; + } + + function maskKey(key) { + var k = String(key || ''); + return k.length > 12 ? k.slice(0, 8) + '...' + k.slice(-4) : k; + } + + var advanced = document.getElementById('acct-advanced'); + var keyRowFilled = false; + if (advanced) { + advanced.addEventListener('toggle', function() { + // Only on open, and only once it has really succeeded: a failed reveal + // must leave the fold able to try again on the next open, and a + // successful one must not overwrite a key the reader pressed Show on. + if (!advanced.open || keyRowFilled) return; + var el = document.getElementById('api-key'); + accountKey().then(function(key) { + keyRowFilled = true; + el.textContent = maskKey(key); + }).catch(function() { + el.textContent = 'Unavailable'; + }); + }); + } + document.getElementById('copy-key').addEventListener('click', async function() { var btn = this; - const res = await fetch('/api/user/account/key', { credentials: 'include' }); - if (!res.ok) { btn.textContent = 'Failed'; setTimeout(function(){ btn.textContent = 'Copy'; }, 2000); return; } - const data = await res.json(); const original = btn.textContent; - const ok = await Promise.resolve(_copyText(data.api_key)); + let key; + try { key = await accountKey(); } + catch (e) { btn.textContent = 'Failed'; setTimeout(function(){ btn.textContent = original; }, 2000); return; } + const ok = await Promise.resolve(_copyText(key)); if (ok) { btn.textContent = 'Copied!'; } else { // Last-resort path for Safari/WebKit with VPN extensions that block writes entirely. // Show the key so the user can select + ⌘-C manually. - document.getElementById('api-key').textContent = data.api_key; + document.getElementById('api-key').textContent = key; btn.textContent = 'Shown — ⌘-C'; } setTimeout(function(){ btn.textContent = original; }, 2500); }); document.getElementById('show-key').addEventListener('click', async function() { - const res = await fetch('/api/user/account/key', { credentials: 'include' }); - if (res.ok) { - const data = await res.json(); - document.getElementById('api-key').textContent = data.api_key; + try { + document.getElementById('api-key').textContent = await accountKey(); + } catch (e) { + document.getElementById('api-key').textContent = 'Unavailable'; } }); diff --git a/frontend/js/app-session-token.js b/frontend/js/app-session-token.js new file mode 100644 index 00000000..025308eb --- /dev/null +++ b/frontend/js/app-session-token.js @@ -0,0 +1,108 @@ +/* The credential the signed-in app pages run on. + * + * WHAT THIS REPLACES. /pricing and /dashboard used to call + * GET /api/user/account/key, take the account's pgp_ key into a variable and + * authenticate to the relay with it. That key has no expiry and no scope: once + * it was in the tab, anything that got to run on the page could read it and + * keep it, and it stayed a full data-plane credential afterwards. #401 fixed + * exactly that for /parashare; this file is the same trade for the other two + * pages, so the key stops reaching the browser on the normal path at all. + * + * WHAT IT HANDS OUT. POST /api/user/app/token mints a pst_ session token with + * purpose `app`: fifteen minutes, and the relay accepts it on three routes + * (POST /v2/billing/checkout, GET /v2/user/history, + * GET /v2/parasign/audit-export) and refuses it on everything else. It is a + * DIFFERENT token from the one /parashare holds, not a wider one: neither + * allowlist contains the other, so giving these pages a credential did not give + * the send page anything extra. See relay/lib/session-token.js for the lists. + * + * WHAT IT DOES NOT DO. + * * it never persists. No localStorage, no sessionStorage, no cookie: the + * token lives in this closure and dies with the tab. A credential written + * to storage outlives the reason it was minted, which is the bug #401 also + * closed on /parashare; + * * it never falls back to the api-key. If the mint fails, the caller gets an + * error and shows it. A fallback would put the key back in the browser on + * exactly the days something is already wrong; + * * it does not renew on a timer. Nothing here polls, so a tab left open on + * /pricing holds an expired string and nothing else. The next call mints. + * + * REFRESH. Two ways, both driven by use rather than by the clock: a token + * inside its last thirty seconds is treated as spent (a request started at + * 14:59 must not arrive at 15:01), and a 401 from the relay makes the caller + * ask again with force = true. One retry, never a loop: a second 401 is a real + * refusal, and retrying it would turn a signed-out tab into a mint loop. + * + * CSP-safe: external file, no inline script. Classic script, no module, because + * both callers are classic scripts. + */ +(function () { + 'use strict'; + + var TOKEN_URL = '/api/user/app/token'; + /* The margin, in ms. A request that leaves with 30 s of life on the token + * arrives with less; the relay judges on arrival. */ + var SPEND_MARGIN_MS = 30000; + + var cached = null; /* { token: string, expires_at: number } */ + var inflight = null; /* the promise of a mint already on the wire */ + + function fresh(entry) { + return !!entry && typeof entry.token === 'string' && + entry.expires_at - Date.now() > SPEND_MARGIN_MS; + } + + function mint() { + if (inflight) return inflight; + inflight = fetch(TOKEN_URL, { + method: 'POST', + credentials: 'include', + headers: { Accept: 'application/json' }, + cache: 'no-store' + }).then(function (r) { + if (!r.ok) throw new Error('token_http_' + r.status); + return r.json(); + }).then(function (j) { + if (!j || !j.token) throw new Error('token_unavailable'); + /* expires_in_s is the relay's own TTL, passed through by the admin. A + * response without it is not trusted to a longer life than the shortest + * one the relay can hand out. */ + var ttl = Number(j.expires_in_s); + if (!isFinite(ttl) || ttl <= 0) ttl = 60; + cached = { token: j.token, expires_at: Date.now() + ttl * 1000 }; + return cached.token; + }).catch(function (err) { + cached = null; + throw err; + }).then(function (t) { + inflight = null; + return t; + }, function (err) { + inflight = null; + throw err; + }); + return inflight; + } + + /* The token to put in an Authorization header, minting one when the one in + * hand is spent. force = true throws the current one away first, which is + * what a caller does after a 401. */ + function get(force) { + if (force) cached = null; + if (fresh(cached)) return Promise.resolve(cached.token); + return mint(); + } + + /* Run `send(token)` with a token, and once more with a fresh one if the relay + * answers 401. `send` must resolve to a Response. Every caller wants this + * shape, and writing the retry once is the only way both pages retry the + * same. */ + function withToken(send) { + return get(false).then(send).then(function (r) { + if (r && r.status === 401) return get(true).then(send); + return r; + }); + } + + window.paAppToken = { get: get, withToken: withToken }; +})(); diff --git a/frontend/js/dashboard-history.js b/frontend/js/dashboard-history.js index 5432de71..0dcc9e2f 100644 --- a/frontend/js/dashboard-history.js +++ b/frontend/js/dashboard-history.js @@ -5,10 +5,19 @@ * GET /v2/user/history per-account send/envelope history (Pro+) * GET /v2/parasign/audit-export signing-audit export, CSV or JSON (Business+) * - * Both are gated by X-Api-Key (a pgp_ key). The browser never stores that key: - * it reveals the account's own primary key on demand via the session-cookie - * endpoint /api/user/account/key (the same source the developer dashboard uses), - * calls the relay, then drops it. A 403 renders an honest upgrade/lock message. + * THE CREDENTIAL. Both relay routes are authenticated. This file used to reveal + * the account's own pgp_ key through /api/user/account/key and send it as + * X-Api-Key: an unscoped credential with no expiry, in the tab for as long as + * it stayed open. It now runs on the short-lived scoped token /parashare got in + * #401. js/app-session-token.js mints a pst_ token with purpose `app`, the + * relay accepts it on exactly GET /v2/user/history, + * GET /v2/parasign/audit-export and POST /v2/billing/checkout, and refuses it + * on everything else, including every other route under /v2/user/*. Fifteen + * minutes, held in memory, never persisted. A 403 still renders an honest + * upgrade/lock message; a 401 mints once more before it is believed. + * + * Nothing is fetched when the page loads: the token is minted on the click that + * needs it. tests/app-pages-no-api-key.test.mjs pins that. * * ASCII-only. Vanilla JS, no libraries. */ @@ -51,22 +60,14 @@ })[s] || (s || 'event'); } - // Reveal (and cache for the page lifetime) the account's own primary pgp_ key - // via the session-cookie endpoint. Never persisted anywhere. - var _keyPromise = null; - function getApiKey() { - if (_keyPromise) return _keyPromise; - _keyPromise = fetch('/api/user/account/key', { - credentials: 'include', headers: { Accept: 'application/json' }, cache: 'no-store' - }).then(function (r) { - if (!r.ok) throw new Error('key_http_' + r.status); - return r.json(); - }).then(function (j) { - var k = j && j.api_key; - if (!k) throw new Error('key_unavailable'); - return k; - }); - return _keyPromise; + // One relay call on an app session token. `send(token)` must return the fetch + // promise; paAppToken.withToken mints one, and mints a second and retries once + // if the relay answers 401, so a tab open past the fifteen minutes recovers + // instead of telling the reader to sign in again. The retry lives in the + // helper so this file and pricing-billing.js cannot drift apart on it. + function relayCall(send) { + if (!window.paAppToken) return Promise.reject(new Error('token_unavailable')); + return window.paAppToken.withToken(send); } function upsell(msg, planPath) { @@ -94,13 +95,13 @@ var orig = histLoad.textContent; histLoad.textContent = 'Loading...'; histBody.innerHTML = '
Loading your history...
'; - getApiKey().then(function (key) { + relayCall(function (tok) { return fetch('/v2/user/history?limit=100', { - headers: { 'X-Api-Key': key, Accept: 'application/json' }, cache: 'no-store' - }).then(function (r) { - return r.json().then(function (j) { return { status: r.status, body: j }; }) - .catch(function () { return { status: r.status, body: {} }; }); + headers: { Authorization: 'Bearer ' + tok, Accept: 'application/json' }, cache: 'no-store' }); + }).then(function (r) { + return r.json().then(function (j) { return { status: r.status, body: j }; }) + .catch(function () { return { status: r.status, body: {} }; }); }).then(function (res) { histLoad.disabled = false; histLoad.textContent = orig; if (res.status === 200) { renderHistory(res.body && res.body.entries); return; } @@ -139,16 +140,16 @@ btn.textContent = 'Preparing...'; var isCsv = format === 'csv'; var url = isCsv ? '/v2/parasign/audit-export?format=csv' : '/v2/parasign/audit-export'; - getApiKey().then(function (key) { + relayCall(function (tok) { return fetch(url, { - headers: { 'X-Api-Key': key, Accept: isCsv ? 'text/csv' : 'application/json' }, cache: 'no-store' - }).then(function (r) { - if (r.status === 200) { - return r.blob().then(function (blob) { return { status: 200, blob: blob }; }); - } - return r.json().then(function (j) { return { status: r.status, body: j }; }) - .catch(function () { return { status: r.status, body: {} }; }); + headers: { Authorization: 'Bearer ' + tok, Accept: isCsv ? 'text/csv' : 'application/json' }, cache: 'no-store' }); + }).then(function (r) { + if (r.status === 200) { + return r.blob().then(function (blob) { return { status: 200, blob: blob }; }); + } + return r.json().then(function (j) { return { status: r.status, body: j }; }) + .catch(function () { return { status: r.status, body: {} }; }); }).then(function (res) { buttons.forEach(function (b) { if (b) b.disabled = false; }); btn.textContent = orig; diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js index 7b2077cb..8c9699c8 100644 --- a/frontend/js/dashboard.js +++ b/frontend/js/dashboard.js @@ -623,10 +623,16 @@ : '
No activity' + (f ? ' matches "' + esc(opsFilter) + '"' : ' yet') + '.
'; } function renderOps(d) { + // No key on this page, not even a masked one. /account is the one page that + // shows the account key, behind its "Advanced account key" fold and only + // when that fold is opened; /dashboard authenticates to the relay with a + // short-lived scoped pst_ token instead (js/app-session-token.js), so it has + // no reason to hold or print a key shape at all. The overview endpoint stopped + // sending key_masked with it. var keysEl = document.getElementById('dh-ops-keys'); if (keysEl) { keysEl.innerHTML = - '
' + esc(d.key_masked || '--') + 'primary
' + + '' + '
last usedtracked via activity
'; } var q = d.quota || { transfers: 0, signs: 0, caps: {} }; diff --git a/frontend/js/pricing-billing.js b/frontend/js/pricing-billing.js index ad46c877..c0ca8413 100644 --- a/frontend/js/pricing-billing.js +++ b/frontend/js/pricing-billing.js @@ -14,8 +14,20 @@ * cannot honour. The href stays as a real link so it survives without JS, but * every scripted click goes through checkout. * - * CSP-safe: external file, no inline script. Same session->key bridge as - * dashboard-history.js (/api/user/account/key, never persisted). + * CSP-safe: external file, no inline script. + * + * THE CREDENTIAL. This page used to fetch the account's pgp_ key from + * /api/user/account/key and send it as X-Api-Key on the checkout call. That key + * has no expiry and no scope, so a click on a price button left a full + * data-plane credential in the tab for as long as it stayed open. It now runs + * on the same kind of short-lived scoped token /parashare got in #401: js/ + * app-session-token.js mints a pst_ token with purpose `app`, which the relay + * accepts on POST /v2/billing/checkout and two dashboard reads, and refuses + * everywhere else. Fifteen minutes, held in memory, never persisted. + * + * NOTHING IS FETCHED ON LOAD. The token is minted on the first click, not when + * the page opens, so simply reading the prices asks for no credential at all. + * tests/app-pages-no-api-key.test.mjs pins that. */ (function () { 'use strict'; @@ -23,26 +35,24 @@ var buttons = document.querySelectorAll('a[data-billing-product]'); if (!buttons.length) return; - var keyPromise = null; - function getApiKey() { - if (keyPromise) return keyPromise; - keyPromise = fetch('/api/user/account/key', { - credentials: 'include', headers: { Accept: 'application/json' }, cache: 'no-store' - }).then(function (r) { - if (!r.ok) throw new Error('key_http_' + r.status); - return r.json(); - }).then(function (j) { - if (!j || !j.api_key) throw new Error('key_unavailable'); - return j.api_key; - }); - return keyPromise; - } - function checkout(btn) { - return getApiKey().then(function (key) { + /* A missing helper is a broken page, not a reason to reach for the key. The + * error travels the same route as a failed mint, so the buyer sees the same + * honest message instead of a silent no-op. + * + * withToken does the one retry on 401 with a freshly minted token: a tab + * left open past the fifteen minutes must not send the buyer to the login + * page for a session that is still perfectly valid. A second 401 is a real + * refusal and falls through to the sign-in branch below. */ + if (!window.paAppToken) return Promise.reject(new Error('token_unavailable')); + return window.paAppToken.withToken(function (tok) { return fetch('/v2/billing/checkout', { method: 'POST', - headers: { 'X-Api-Key': key, 'Content-Type': 'application/json', Accept: 'application/json' }, + headers: { + Authorization: 'Bearer ' + tok, + 'Content-Type': 'application/json', + Accept: 'application/json' + }, body: JSON.stringify({ product: btn.getAttribute('data-billing-product'), plan: btn.getAttribute('data-billing-plan'), @@ -147,8 +157,8 @@ * every signed-out visitor who clicked a price button landed on the * error page. The rest of the site already linked /auth/login; this * one line was the odd one out. */ - if (msg.indexOf('key_http_401') === 0 || msg.indexOf('key_http_403') === 0 || - msg === 'key_unavailable' || msg.indexOf('checkout_http_401') === 0 || + if (msg.indexOf('token_http_401') === 0 || msg.indexOf('token_http_403') === 0 || + msg === 'token_unavailable' || msg.indexOf('checkout_http_401') === 0 || msg.indexOf('checkout_http_403') === 0) { rememberIntent(btn); window.location.href = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search); diff --git a/frontend/pricing.html b/frontend/pricing.html index 15e21293..44b178c1 100644 --- a/frontend/pricing.html +++ b/frontend/pricing.html @@ -627,6 +627,7 @@

- + + diff --git a/frontend/privacy.html b/frontend/privacy.html index 3ff8aef3..10bf95b1 100644 --- a/frontend/privacy.html +++ b/frontend/privacy.html @@ -274,8 +274,8 @@

Your API key, and which pages hold it

Your account has an API key. It is stored on our servers; nothing in the list above keeps it in your browser, and no page writes it to local storage. Some pages do load it into memory for as long as their tab is open, and we would rather name them than leave you to guess.

  • Sending a file (/parashare) does not. It asks our server for a session token instead. That token lives fifteen minutes, the relay accepts it on the five requests a transfer makes and refuses it on everything else, and it is held in the page’s memory only, so closing the tab ends it. Revoking your API key revokes every token minted from it. On a self-hosted relay without our admin panel you can still type a key by hand on that page, and then it is in that browser for as long as the tab is open, which is why the page says so where it offers it.
  • -
  • Your account page (/account) does, on purpose: showing you your key is what the page is for.
  • -
  • The pricing and dashboard pages (/pricing, /dashboard) do, because they still authenticate to the relay with the key itself to start a checkout and to read your usage. We are moving them to the same kind of scoped token; until we have, a tab on either of those pages holds the key in memory.
  • +
  • Your account page (/account) does, but only when you ask it to. Showing you your key is what that page is for, so it is the one page that still loads it. Opening Advanced account key is what fetches it; a visit that never opens that section never asks our server for the key at all, and nothing of it is on the page.
  • +
  • The pricing and dashboard pages (/pricing, /dashboard) no longer do. They used to authenticate to the relay with the key itself, to start a checkout and to read your history. They now use the same kind of session token as the send page: a different token, minted for those pages, which the relay accepts on three requests (starting a checkout, reading your own history, exporting your own signing audit) and refuses on everything else. It is not the send page’s token and cannot do what that one does, or the other way round.

Nothing on this list survives the tab. Closing the page ends it, and a later visit starts again from the server.

diff --git a/relay/lib/session-token.js b/relay/lib/session-token.js index 18fca87f..ea7e11af 100644 --- a/relay/lib/session-token.js +++ b/relay/lib/session-token.js @@ -110,12 +110,72 @@ const SCOPE = [ { method: 'POST', path: '/v2/inbound' }, ]; -function scopeAllows(method, path) { +// ── The second purpose: the signed-in app pages ────────────────────────────── +// A token is minted FOR A PURPOSE, and the purpose picks which allowlist it is +// judged against. The two lists are DISJOINT and neither is a superset of the +// other: a token minted on /parashare cannot start a checkout, and a token +// minted on /pricing cannot upload a file. That is the only reason this feature +// could grow past the transfer path at all. Merging both into one flat SCOPE +// would have widened the ParaSend token by three routes to give three other +// pages a credential they needed, which is how a narrow credential quietly +// becomes an api-key again. +// +// Why each route is here, and nothing else is: +// +// POST /v2/billing/checkout /pricing. Pressing a price button creates +// a Mollie payment whose metadata names the account, and that call is +// authenticated to the relay. Until now pricing-billing.js fetched the +// account's pgp_ key to make it, so every visit to /pricing that ended in a +// click put a permanent data-plane credential in the tab. The route creates +// a payment; it moves no money and reveals nothing about the account. +// +// GET /v2/user/history /dashboard, "Your history". A read-only +// projection over the account's own audit chain: identifiers, status and +// timing, never a payload, download token or key. This one path out of +// /v2/user/* is named ON ITS OWN, never a prefix, precisely because the +// rest of /v2/user/* is the signing-key and TOTP surface that the ParaSend +// scope note below refuses by name. A read of your own history is not that. +// +// GET /v2/parasign/audit-export /dashboard, the signing-audit export. Also +// read-only over the same chain, tier-gated at Business+ by the route +// itself. It is an export of what the account already did. +// +// What stays out, under BOTH purposes: /v2/keys, the signing-key and TOTP +// routes, /v2/outbound, /v2/admin/*, and /v2/session-token itself. No token +// mints another one, whatever it was minted for. +const APP_SCOPE = [ + { method: 'POST', path: '/v2/billing/checkout' }, + { method: 'GET', path: '/v2/user/history' }, + { method: 'GET', path: '/v2/parasign/audit-export' }, +]; + +const PURPOSE_PARASEND = 'parasend'; +const PURPOSE_APP = 'app'; +const SCOPES = { [PURPOSE_PARASEND]: SCOPE, [PURPOSE_APP]: APP_SCOPE }; + +// A stored record written before purposes existed has no `p` field, and it can +// only be a ParaSend token, so an ABSENT purpose means parasend. That is the +// only lenient case, and it is deliberately `undefined` alone: null, a number, +// an object and an unknown word all come back null, and null opens nothing. +// +// This asymmetry is the whole safety property. If null had folded onto parasend +// too, then a record carrying a purpose this build does not understand would be +// judged against the transfer allowlist instead of being refused, which is a +// credential doing something nobody minted it to do. So: unknown means no +// scope, and resolve() below turns that into no principal at all. +function normalisePurpose(purpose) { + if (purpose === undefined || purpose === '') return PURPOSE_PARASEND; + return Object.prototype.hasOwnProperty.call(SCOPES, purpose) ? purpose : null; +} + +function scopeAllows(method, path, purpose = PURPOSE_PARASEND) { 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) => { + const rules = SCOPES[normalisePurpose(purpose)]; + if (!rules) return false; + return rules.some((rule) => { if (rule.method && rule.method !== m) return false; return rule.re ? rule.re.test(path) : rule.path === path; }); @@ -142,9 +202,14 @@ async function pruneOwnerIndex(redisClient, idx) { // the maximum number of live tokens. 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()) { +async function mint(redisClient, owner, now = Date.now(), purpose = PURPOSE_PARASEND) { if (!redisClient) throw new Error('session-token: no redis client'); if (!owner || typeof owner !== 'string') throw new Error('session-token: no owner key'); + // A purpose this module does not know is refused HERE rather than written and + // refused later: a token that opens nothing is a support ticket, and minting + // one silently is how a typo in a caller becomes an hour of debugging. + const p = normalisePurpose(purpose); + if (!p) throw new Error('session-token: unknown purpose'); const idx = ownerKey(owner); // The ceiling. sCard first because it is one round trip and almost always @@ -165,13 +230,13 @@ async function mint(redisClient, owner, now = Date.now()) { // 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({ kh: keyHash(owner), exp: expires_ms }), { EX: TTL_S }); + await redisClient.set(tokenKey(token), JSON.stringify({ kh: keyHash(owner), exp: expires_ms, p }), { 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. await redisClient.sAdd(idx, token); await redisClient.expire(idx, TTL_S + 60); - return { token, expires_ms, expires_in_s: TTL_S }; + return { token, expires_ms, expires_in_s: TTL_S, purpose: p }; } // The owner key a token stands for, or null. Null covers every refusal there @@ -208,7 +273,16 @@ async function resolve(redisClient, token, resolveOwner, now = Date.now()) { if (now > rec.exp) return null; const key = resolveOwner(rec.kh); if (!key || typeof key !== 'string') return null; - return { key, expires_ms: rec.exp }; + // The purpose travels back with the principal, because the caller cannot + // decide the scope without it. A record with no `p` predates purposes and is + // a ParaSend token. A record with a `p` this build does not know is refused + // OUTRIGHT, here, rather than handed back with an empty scope: a credential + // whose authority this code cannot compute is not a principal, and leaving it + // to the gate would make every future caller of resolve() responsible for a + // rule that belongs in one place. + const purpose = normalisePurpose(rec.p); + if (!purpose) return null; + return { key, expires_ms: rec.exp, purpose }; } // Every live token for one api-key, gone. Called when the key is revoked. @@ -230,7 +304,8 @@ async function revokeForKey(redisClient, owner) { } module.exports = { - TTL_S, PREFIX, SCOPE, MAX_LIVE_PER_ACCOUNT, + TTL_S, PREFIX, SCOPE, APP_SCOPE, SCOPES, MAX_LIVE_PER_ACCOUNT, + PURPOSE_PARASEND, PURPOSE_APP, normalisePurpose, isSessionToken, bearerToken, scopeAllows, keyHash, mint, resolve, revokeForKey, pruneOwnerIndex, tokenKey, ownerKey, diff --git a/relay/relay.js b/relay/relay.js index 73f6419b..e6a5389a 100644 --- a/relay/relay.js +++ b/relay/relay.js @@ -2557,6 +2557,11 @@ async function handleRelayRequest(req, res) { // a 503, so the browser retries instead of throwing the sender back to login. const _bearer = sessionTokens.bearerToken(req.headers['authorization'] || ''); let viaSessionToken = false; + // Which allowlist this token is judged against, set from the record the token + // resolves to and never from anything the caller sent. A browser picks a + // purpose when it MINTS (through the admin plane, on a signed-in session); + // once minted, the purpose is a property of the credential. + let sessionTokenPurpose = null; if (!apiKey && sessionTokens.isSessionToken(_bearer)) { if (!redisClient) { res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '5' }); @@ -2564,7 +2569,7 @@ async function handleRelayRequest(req, res) { } try { const _pst = await sessionTokens.resolve(redisClient, _bearer, apiKeyFromHash); - if (_pst) { apiKey = _pst.key; viaSessionToken = true; } + if (_pst) { apiKey = _pst.key; viaSessionToken = true; sessionTokenPurpose = _pst.purpose; } } catch (err) { if (redisOutage503(err, res)) return; throw err; @@ -2624,12 +2629,12 @@ async function handleRelayRequest(req, res) { // 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 }); + if (viaSessionToken && !sessionTokens.scopeAllows(req.method, path, sessionTokenPurpose)) { + log('warn', 'session_token_out_of_scope', { method: req.method, path, purpose: sessionTokenPurpose }); 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', + hint: 'a pst_ session token opens the short list of routes it was minted for; use an API key for anything else', })); } @@ -3041,8 +3046,27 @@ async function handleRelayRequest(req, res) { res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '5' }); return res.end(J({ error: 'redis_unavailable', hint: 'session tokens need the relay store' })); } + // The purpose picks the allowlist the minted token will be judged against. + // It is read from the admin plane's body, which is the only caller that can + // reach this route at all (X-Internal-Auth above), and an unknown word is + // refused rather than folded onto a default: handing back a token that + // opens the wrong three routes is worse than handing back nothing. An + // ABSENT purpose is the ParaSend one, so the admin route that predates + // purposes keeps working byte for byte. + let _purpose = sessionTokens.PURPOSE_PARASEND; try { - const minted = await sessionTokens.mint(redisClient, apiKey); + const b = JSON.parse((await readBody(req, 1024)).toString() || '{}'); + if (b && b.purpose !== undefined && b.purpose !== null) _purpose = String(b.purpose); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(J({ error: 'bad_json' })); + } + if (!sessionTokens.normalisePurpose(_purpose)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(J({ error: 'unknown_purpose', hint: 'purpose must be one of: parasend, app' })); + } + try { + const minted = await sessionTokens.mint(redisClient, apiKey, Date.now(), _purpose); // The per-account ceiling. Twenty live tokens is far above any honest // use of a page that mints one per load, so this is a signed-in session // being used as a credential factory. 429 rather than 403: the account is @@ -3057,6 +3081,7 @@ async function handleRelayRequest(req, res) { log('info', 'session_token_minted', { account: String(owner.account_id || apiKey).slice(0, 12), ttl_s: minted.expires_in_s, + purpose: minted.purpose, }); res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(J({ @@ -3064,6 +3089,7 @@ async function handleRelayRequest(req, res) { token: minted.token, expires_ms: minted.expires_ms, expires_in_s: minted.expires_in_s, + purpose: minted.purpose, })); } catch (err) { if (redisOutage503(err, res)) return; diff --git a/relay/test/route-session-token.test.js b/relay/test/route-session-token.test.js index 204e8530..cb5f3fed 100644 --- a/relay/test/route-session-token.test.js +++ b/relay/test/route-session-token.test.js @@ -85,9 +85,10 @@ after(async () => { }); // Mint the way the admin does: the internal header plus the session's own key. -async function mint(key = OWNER, server = srv) { +async function mint(key = OWNER, server = srv, purpose = undefined) { const r = await server.post('/v2/session-token', { headers: { 'X-Internal-Auth': INTERNAL, 'X-Api-Key': key }, + ...(purpose === undefined ? {} : { body: { purpose } }), }); return r; } @@ -223,6 +224,77 @@ test('THE SCOPE: every route outside the transfer path is 403, above the handler did(); }); +// ── 3b. The other purpose, over HTTP ───────────────────────────────────────── +// /pricing and /dashboard run on a token minted with purpose `app`. What matters +// on the wire is that the two purposes are not interchangeable: each opens its +// own three-or-five routes and is 403 on the other's. A single flat allowlist +// would pass the "app routes work" half of this and fail the wall below, which +// is why both halves are here. + +test('an app-purpose token opens the three app routes and nothing from the transfer path', async (t) => { + if (!rc) return t.skip('no redis'); + const minted = await mint(OWNER, srv, 'app'); + assert.strictEqual(minted.status, 200, minted.text); + assert.strictEqual(minted.json.purpose, 'app', 'the relay must say what it minted'); + const h = bearer(minted.json.token); + + // In scope. The statuses are the routes' own answers (a community account is + // not entitled to history or the export, and checkout needs a real body), and + // any of them is proof the scope gate let the request through: the gate + // answers 403 with error session_token_out_of_scope and nothing else does. + for (const [method, path, body] of [ + ['GET', '/v2/user/history', undefined], + ['GET', '/v2/parasign/audit-export', undefined], + ['POST', '/v2/billing/checkout', { product: 'parasend', plan: 'pro', interval: 'month' }], + ]) { + const r = await srv.req(method, path, { headers: h, body }); + assert.notStrictEqual(r.json && r.json.error, 'session_token_out_of_scope', + `${method} ${path} was refused by scope, and it is what an app token is for: ${r.text}`); + } + + // Out of scope: the whole ParaSend transfer path, which this token was never + // minted to walk. + for (const [method, path, body] of [ + ['POST', '/v2/ws-ticket', undefined], + ['POST', '/v2/pubkey', { device_id: 'inv_' + 'a'.repeat(32), ecdh_pub: 'aa'.repeat(32), kyber_pub: 'bb'.repeat(32) }], + ['POST', '/v2/inbound', undefined], + ]) { + 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 ParaSend token is 403 on every app route: the two lists are disjoint', async (t) => { + if (!rc) return t.skip('no redis'); + const { token } = (await mint()).json; + const h = bearer(token); + for (const [method, path, body] of [ + ['GET', '/v2/user/history', undefined], + ['GET', '/v2/parasign/audit-export', undefined], + ['POST', '/v2/billing/checkout', { product: 'parasend', plan: 'pro', interval: 'month' }], + ]) { + 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('an unknown purpose is refused at the mint, so no unusable token is ever handed out', async (t) => { + if (!rc) return t.skip('no redis'); + const r = await mint(OWNER, srv, 'admin'); + assert.strictEqual(r.status, 400, r.text); + assert.strictEqual(r.json.error, 'unknown_purpose'); + // And the default is still the ParaSend one, so the admin route that predates + // purposes keeps working without sending a purpose at all. + const plain = await mint(); + assert.strictEqual(plain.status, 200, plain.text); + assert.strictEqual(plain.json.purpose, 'parasend'); + 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 diff --git a/relay/test/session-token.test.js b/relay/test/session-token.test.js index 4a951a29..ff42a638 100644 --- a/relay/test/session-token.test.js +++ b/relay/test/session-token.test.js @@ -170,6 +170,121 @@ test('the per-device pubkey read is exactly one path segment deep', () => { did(); }); +// ── 2b. The second purpose, and the wall between the two ───────────────────── +// /pricing and /dashboard stopped fetching the account's pgp_ key and now run +// on a session token of their own. The whole reason that was allowed to happen +// is that it is a DIFFERENT token, not a wider one: the two allowlists are +// disjoint, and neither page can do the other's work with the credential it +// holds. If that ever stops being true, the honest thing is for these cases to +// go red rather than for the send page to quietly gain a checkout. + +test('the app purpose opens exactly the three routes the signed-in pages need', () => { + const open = [ + // /pricing: pressing a price button creates the Mollie payment. + ['POST', '/v2/billing/checkout'], + // /dashboard: the account's own history, and its own signing-audit export. + ['GET', '/v2/user/history'], + ['GET', '/v2/parasign/audit-export'], + ]; + for (const [method, path] of open) { + assert.strictEqual(st.scopeAllows(method, path, 'app'), true, + `${method} ${path} is what an app-purpose token exists for`); + } + assert.strictEqual(st.APP_SCOPE.length, 3, + 'the app allowlist grew; /privacy names three requests, so change the page with the code'); + did(); +}); + +test('THE WALL: neither purpose can do the other\'s work', () => { + for (const [method, path] of [['POST', '/v2/inbound'], ['POST', '/v2/ws-ticket'], + ['POST', '/v2/pubkey'], ['GET', '/v2/check-key']]) { + assert.strictEqual(st.scopeAllows(method, path, 'app'), false, + `${method} ${path} is a transfer route; a token minted on /pricing must not walk it`); + } + for (const [method, path] of [['POST', '/v2/billing/checkout'], ['GET', '/v2/user/history'], + ['GET', '/v2/parasign/audit-export']]) { + assert.strictEqual(st.scopeAllows(method, path, 'parasend'), false, + `${method} ${path} is an app route; a token minted on /parashare must not reach it. ` + + 'Merging the two lists is how a narrow credential becomes an api-key again.'); + } + did(); +}); + +test('the app purpose shuts everything the review named, exactly as parasend does', () => { + const shut = [ + ['GET', '/v2/keys'], ['POST', '/v2/keys'], + ['POST', '/v2/user/signing-key'], ['GET', '/v2/user/signing-key'], + ['POST', '/v2/user/setup-totp'], ['POST', '/v2/user/envelopes'], + ['GET', '/v2/outbound/' + 'a'.repeat(64)], + ['GET', '/v2/audit'], + ['GET', '/v2/admin/keys'], ['POST', '/v2/admin/keys/revoke'], + // No token mints another one, whatever it was minted for. + ['POST', '/v2/session-token'], + ['POST', '/v2/envelopes'], ['POST', '/v1/envelopes'], + ]; + for (const [method, path] of shut) { + assert.strictEqual(st.scopeAllows(method, path, 'app'), false, + `${method} ${path} is reachable with an app session token`); + } + // /v2/user/history is named ON ITS OWN, never as a prefix: the rest of + // /v2/user/* stays shut, and a deeper path under history is a different route. + assert.strictEqual(st.scopeAllows('GET', '/v2/user/history/all', 'app'), false); + assert.strictEqual(st.scopeAllows('POST', '/v2/user/history', 'app'), false); + assert.strictEqual(st.scopeAllows('POST', '/v2/parasign/audit-export', 'app'), false); + did(); +}); + +test('an absent purpose is parasend; an unknown purpose opens nothing at all', () => { + // A record written before purposes existed carries no `p` and can only be a + // ParaSend token, so the default has to be parasend or every live token dies + // on deploy. An unknown word is the other direction and must fail CLOSED: a + // build that does not know the purpose cannot know the scope either. + assert.strictEqual(st.normalisePurpose(undefined), 'parasend'); + assert.strictEqual(st.normalisePurpose(''), 'parasend'); + assert.strictEqual(st.normalisePurpose('parasend'), 'parasend'); + assert.strictEqual(st.normalisePurpose('app'), 'app'); + // null is NOT the absent case. Only `undefined` folds onto parasend, so a + // purpose that arrived as an explicit null opens nothing rather than the + // transfer list. + for (const bogus of [null, 'admin', 'APP', 'app ', 'toString', '__proto__', 0, {}]) { + assert.strictEqual(st.normalisePurpose(bogus), null, `${JSON.stringify(bogus)} is not a purpose`); + assert.strictEqual(st.scopeAllows('POST', '/v2/inbound', bogus), false, + 'an unknown purpose must open nothing, not fall back to the transfer list'); + assert.strictEqual(st.scopeAllows('POST', '/v2/billing/checkout', bogus), false); + } + did(); +}); + +test('mint records the purpose, resolve hands it back, and an unknown one is never written', async () => { + const r = fakeRedis(); + const app = await st.mint(r, 'pgp_owner_demo', 5_000, 'app'); + assert.strictEqual(app.purpose, 'app'); + assert.deepStrictEqual(await st.resolve(r, app.token, OWNERS, 6_000), + { key: 'pgp_owner_demo', expires_ms: app.expires_ms, purpose: 'app' }); + + const send = await st.mint(r, 'pgp_owner_demo', 5_000); + assert.strictEqual(send.purpose, 'parasend', 'the default is the purpose that predates purposes'); + + // A record with no `p` at all: the shape redis still holds from the build + // before this one. It must resolve, and it must resolve as parasend. + const legacy = 'pst_' + '7'.repeat(64); + await r.set(st.tokenKey(legacy), JSON.stringify({ kh: st.keyHash('pgp_owner_demo'), exp: 9_000 })); + assert.deepStrictEqual(await st.resolve(r, legacy, OWNERS, 6_000), + { key: 'pgp_owner_demo', expires_ms: 9_000, purpose: 'parasend' }); + + // A record with a purpose this build does not know is no principal at all. It + // must NOT come back as a parasend token, which is what a lenient default + // would have made it. + const alien = 'pst_' + '8'.repeat(64); + await r.set(st.tokenKey(alien), JSON.stringify({ kh: st.keyHash('pgp_owner_demo'), exp: 9_000, p: 'admin' })); + assert.strictEqual(await st.resolve(r, alien, OWNERS, 6_000), null, + 'a token whose purpose this build cannot judge must authenticate nobody'); + + await assert.rejects(() => st.mint(r, 'pgp_owner_demo', 5_000, 'admin'), /unknown purpose/, + 'minting a token nothing can use is a support ticket, so it is refused at the mint'); + did(); +}); + // ── 3. The token shape ─────────────────────────────────────────────────────── test('only a pst_ token of the exact minted shape is ever looked up', () => { @@ -215,7 +330,8 @@ test('mint writes one record under the token, with the TTL it promises', async ( assert.strictEqual(out.expires_ms, 1_000_000 + 900_000); const rk = st.tokenKey(out.token); - assert.deepStrictEqual(JSON.parse(r.store.get(rk)), { kh: st.keyHash('pgp_owner_demo'), exp: 1_900_000 }); + assert.deepStrictEqual(JSON.parse(r.store.get(rk)), + { kh: st.keyHash('pgp_owner_demo'), exp: 1_900_000, p: 'parasend' }); assert.strictEqual(r.ttls.get(rk), 900, 'redis must be the thing that expires the token, not a sweeper'); did(); }); @@ -239,7 +355,8 @@ test('THE RECORD HOLDS NO KEY. Everything written to the store is a hash', async // happens not to contain the string. const rec = JSON.parse(r.store.get(st.tokenKey(out.token))); assert.strictEqual(rec.kh, require('crypto').createHash('sha256').update('pgp_owner_demo').digest('hex')); - assert.strictEqual(Object.keys(rec).sort().join(','), 'exp,kh', 'the record carries exactly the hash and the expiry'); + assert.strictEqual(Object.keys(rec).sort().join(','), 'exp,kh,p', + 'the record carries exactly the hash, the expiry and the purpose'); did(); }); @@ -273,7 +390,8 @@ test('the sweep index holds both tokens and outlives neither by much', async () test('a minted token resolves to the api-key it was minted for, through the table', async () => { const r = fakeRedis(); const { token, expires_ms } = await st.mint(r, 'pgp_owner_demo', 5_000); - assert.deepStrictEqual(await st.resolve(r, token, OWNERS, 6_000), { key: 'pgp_owner_demo', expires_ms }); + assert.deepStrictEqual(await st.resolve(r, token, OWNERS, 6_000), + { key: 'pgp_owner_demo', expires_ms, purpose: 'parasend' }); did(); }); @@ -337,7 +455,8 @@ test('EXP IS REQUIRED: a record without a usable expiry is refused, not given th // The control: the same record with a real expiry does resolve, so the case // above is measuring the expiry and not some other refusal. await r.set(st.tokenKey(tok), JSON.stringify({ kh, exp: 2000 })); - assert.deepStrictEqual(await st.resolve(r, tok, OWNERS, 1000), { key: 'pgp_owner_demo', expires_ms: 2000 }); + assert.deepStrictEqual(await st.resolve(r, tok, OWNERS, 1000), + { key: 'pgp_owner_demo', expires_ms: 2000, purpose: 'parasend' }); did(); }); diff --git a/tests/app-pages-no-api-key.test.mjs b/tests/app-pages-no-api-key.test.mjs new file mode 100644 index 00000000..6a842265 --- /dev/null +++ b/tests/app-pages-no-api-key.test.mjs @@ -0,0 +1,177 @@ +// The signed-in pages do not fetch the account key when they load. +// +// WHAT THIS PINS, AND WHY IT IS ITS OWN SUITE. /pricing and /dashboard used to +// call GET /api/user/account/key and authenticate to the relay with the pgp_ +// key itself; /account printed the masked key on load. An api-key has no expiry +// and no scope, so every one of those visits put a full data-plane credential +// (or the shape of one) into the tab, for people who had come to read a price +// or check a quota. They now run on a short-lived scoped pst_ session token +// (relay/lib/session-token.js, purpose `app`), and /account fetches the key only +// when someone opens its "Advanced account key" fold. +// +// A grep would have been cheaper and would have measured the wrong thing. What +// matters is what the PAGE does when a browser loads it: a fetch reintroduced +// through a helper, a shared module or an event that fires on load would pass a +// grep of the three page scripts and fail here. So this drives real Chromium +// over the real static frontend and watches the network. +// +// Verified by sabotage: +// * put the /api/user/account/key fetch back at the top of +// account.inline1.js, or render data.api_key_masked on load, and the +// /account cases go red; +// * make pricing-billing.js or dashboard-history.js fetch the key again and +// the matching page goes red; +// * take the toggle listener out of account.inline1.js and the "opening the +// fold is what asks" case goes red, which is the half that proves the key +// is still reachable where it is supposed to be. +// +// Run: node --test tests/app-pages-no-api-key.test.mjs +import { chromium } from 'playwright'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'frontend'); +const EXE = process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined; +const MIME = { '.js':'text/javascript', '.css':'text/css', '.html':'text/html', '.svg':'image/svg+xml', '.png':'image/png', '.woff2':'font/woff2' }; +const aliases = { '/':'/index.html', '/dashboard':'/dashboard.html', '/account':'/account.html', '/pricing':'/pricing.html' }; + +// The endpoint this whole suite is about. Named once, so a rename cannot make +// the assertions silently vacuous. +const KEY_URL = '/api/user/account/key'; +const FAKE_KEY = 'pgp_live_testkey000000000000cafe'; + +const server = http.createServer((req, res) => { + let pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + pathname = aliases[pathname] || pathname; + const file = path.join(ROOT, pathname); + if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); } + fs.readFile(file, (error, body) => { + if (error) { res.writeHead(404); return res.end(); } + res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' }); + res.end(body); + }); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const ORIGIN = `http://localhost:${server.address().port}`; +const browser = await chromium.launch({ headless:true, ...(EXE ? { executablePath:EXE } : {}) }); +const checks = []; +function ok(name, condition, detail='') { checks.push({ name, pass:!!condition, detail:String(detail) }); } + +// Every /api/** answer a signed-in page can ask for, so nothing fails for a +// reason other than the one under test, plus the recorder. The key endpoint is +// answered too: a 404 would make "no request was made" true for the wrong +// reason on the case where the request IS expected. +const BODIES = { + '/api/user/session/verify': { authenticated: true, email: 'demo@example.com' }, + '/api/user/account': { + email: 'demo@example.com', api_key_masked: 'pgp_live...cafe', plan: 'pro', label: 'Demo', + created_at: '2026-06-01T10:00:00.000Z', backup_codes_remaining: 8, + session_expires_at: '2099-01-01T00:00:00.000Z', sessions: [], + }, + [KEY_URL]: { api_key: FAKE_KEY, revealable: true }, + '/api/user/me': { email: 'demo@example.com', plan: 'pro', usage_purpose: 'organisation' }, + '/api/user/documents': { documents: [] }, + '/api/user/dashboard/overview': { plan: 'pro', quota: { transfers: 1, signs: 1, caps: {} }, audit: [] }, + '/api/user/app/token': { token: 'pst_' + 'ab12cd34'.repeat(8), expires_in_s: 900 }, +}; + +async function openPage(route) { + const page = await browser.newPage({ viewport:{ width:1280, height:900 } }); + const asked = []; + await page.route('**/api/**', (r) => { + const pathname = new URL(r.request().url()).pathname; + asked.push(pathname); + r.fulfill({ status:200, contentType:'application/json', body:JSON.stringify(BODIES[pathname] || {}) }); + }); + await page.goto(ORIGIN + route, { waitUntil:'networkidle' }); + return { page, asked }; +} + +// ── 1. Loading any of the three asks for no key ────────────────────────────── +for (const route of ['/account', '/pricing', '/dashboard']) { + const { page, asked } = await openPage(route); + ok(`${route} asks for no account key when it loads`, + !asked.includes(KEY_URL), asked.join(', ') || 'no /api call at all'); + // And nothing of the key is IN the page either: a masked key rendered from the + // account payload is still the page telling you it holds one. textContent, not + // innerText: the /account key sits inside a closed
, and innerText + // skips what is not laid out, which would make this pass for the wrong reason. + const body = await page.evaluate(() => document.body.textContent); + ok(`${route} shows nothing shaped like an account key on load`, + !/pgp_/.test(body) && !body.includes(FAKE_KEY), (body.match(/pgp_\S*/g) || []).join(', ')); + await page.close(); +} + +// ── 2. But the key is still reachable, behind the fold on /account ─────────── +{ + const { page, asked } = await openPage('/account'); + ok('/account has the Advanced fold, closed', await page.locator('#acct-advanced').count() === 1 && + await page.locator('#acct-advanced').evaluate((node) => !node.open)); + const before = asked.filter((u) => u === KEY_URL).length; + + await page.locator('#acct-advanced > summary').click(); + // The placeholder the HTML ships with, written as an escape: the style guard + // bans the literal character from added lines. + await page.waitForFunction(() => document.getElementById('api-key').textContent.trim() !== '\u2014'); + const after = asked.filter((u) => u === KEY_URL).length; + ok('opening the fold is what asks for the key, exactly once', before === 0 && after === 1, `${before} -> ${after}`); + + // Masked, not whole: opening the fold says "show me the row", and Show says + // "show me the key". Two different acts. + const shown = (await page.locator('#api-key').innerText()).trim(); + ok('the fold shows the key masked, not in full', shown.startsWith('pgp_live') && shown.includes('...') && + shown !== FAKE_KEY, shown); + + await page.locator('#show-key').click(); + await page.waitForFunction((key) => document.getElementById('api-key').textContent.trim() === key, FAKE_KEY); + ok('Show reveals the whole key without asking the server again', + asked.filter((u) => u === KEY_URL).length === 1, asked.filter((u) => u === KEY_URL).length); + await page.close(); +} + +// ── 3. And the token is what the other two use instead ─────────────────────── +// Not on load -- nothing is minted for a visitor who only reads -- but the page +// must carry the helper that mints one, or "no key" would just mean "no +// credential at all" and the checkout would be broken rather than fixed. +{ + const { page, asked } = await openPage('/pricing'); + ok('/pricing mints no session token on load either', + !asked.includes('/api/user/app/token'), asked.join(', ')); + ok('/pricing loads the app session-token helper', + await page.evaluate(() => typeof window.paAppToken?.get === 'function')); + + // And the click really runs on the token. This is the half that makes "no key + // on load" mean something: without it, a page that fetched the key on the + // click instead would pass every case above. + const relayCalls = []; + await page.route('**/v2/billing/checkout', (r) => { + relayCalls.push(r.request().headers()); + r.fulfill({ status:200, contentType:'application/json', body:JSON.stringify({ ok:true, checkout_url:'about:blank' }) }); + }); + const button = page.locator('a[data-billing-product]').first(); + ok('/pricing still has a price button to press', await button.count() === 1); + await button.click(); + // The click is three awaits deep: mint the token, post the checkout, then + // navigate to the payment page. Poll for the checkout call rather than + // sleeping a fixed number of milliseconds, which is the difference between a + // test and a flake. + const deadline = Date.now() + 5000; + while (!relayCalls.length && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50)); + + ok('pressing a price button mints a session token', asked.includes('/api/user/app/token'), asked.join(', ')); + ok('and never asks for the account key on the way', !asked.includes(KEY_URL), asked.join(', ')); + const sent = relayCalls[0] || {}; + ok('the checkout call carries a pst_ bearer and no api-key header', + /^Bearer pst_[0-9a-f]{8}/i.test(sent.authorization || '') && !('x-api-key' in sent), + JSON.stringify({ authorization: sent.authorization, 'x-api-key': sent['x-api-key'] })); + await page.close(); +} + +for (const check of checks) console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name}${check.detail ? ' :: ' + check.detail : ''}`); +await browser.close(); +server.close(); +server.closeAllConnections(); +if (checks.some((check) => !check.pass)) process.exit(1); +console.log(`\napp-pages-no-api-key: ${checks.length} checks passed`); diff --git a/tests/site-claims.test.mjs b/tests/site-claims.test.mjs index 152ea36a..ffa1adc3 100644 --- a/tests/site-claims.test.mjs +++ b/tests/site-claims.test.mjs @@ -2268,14 +2268,25 @@ test('the ParaSend credential /privacy describes is the credential the code impl assert.ok(priv.includes('That token lives fifteen minutes'), 'privacy: the storage section must state the token lifetime'); - // 2. Five requests, and the relay's allowlist is what decides that. + // 2. Five requests for the send page, three for the app pages, and the relay's + // two allowlists are what decide that. They are counted separately because + // they ARE separate: a token minted on /parashare is judged against SCOPE and + // one minted on /pricing against APP_SCOPE, and /privacy makes that promise in + // both directions ("It is not the send page's token and cannot do what that + // one does, or the other way round"). const scope = stripJsComments(read('relay/lib/session-token.js')); - const rules = (scope.slice(scope.indexOf('const SCOPE = ['), scope.indexOf('function scopeAllows')) + const countRules = (from, to) => (scope.slice(scope.indexOf(from), scope.indexOf(to)) .match(/\{ method:/g) || []).length; + const rules = countRules('const SCOPE = [', 'const APP_SCOPE = ['); assert.equal(rules, 5, - `the relay allowlist now has ${rules} entries; /privacy says five, so change the page with the code`); + `the relay's ParaSend allowlist now has ${rules} entries; /privacy says five, so change the page with the code`); assert.ok(priv.includes('the relay accepts it on the five requests a transfer makes and refuses it on everything else'), 'privacy: the storage section must state what the token can and cannot do'); + const appRules = countRules('const APP_SCOPE = [', 'const PURPOSE_PARASEND'); + assert.equal(appRules, 3, + `the relay's app allowlist now has ${appRules} entries; /privacy says three, so change the page with the code`); + assert.ok(priv.includes('which the relay accepts on three requests'), + 'privacy: the pricing/dashboard token must be described by what it can do'); // 3. /parashare really asks for a token, and really does not ask for the key. const ps = read('frontend/js/parashare.page.js'); @@ -2290,10 +2301,12 @@ test('the ParaSend credential /privacy describes is the credential the code impl // pages /privacy admits still hold the key must be exactly the pages that do. // A page that stops fetching it and is still listed is a page telling you it // holds something it does not; one that starts and is not listed is worse. + // + // The list is ONE page now. /pricing and /dashboard moved to an app session + // token, so their files must no longer name the reveal endpoint at all, and + // the ex-holders below are the assertion that they stay moved. const HOLDERS = { '/account': 'frontend/js/account.inline1.js', - '/pricing': 'frontend/js/pricing-billing.js', - '/dashboard': 'frontend/js/dashboard-history.js', }; for (const [where, file] of Object.entries(HOLDERS)) { assert.ok(read(file).includes('/api/user/account/key'), @@ -2301,6 +2314,12 @@ test('the ParaSend credential /privacy describes is the credential the code impl assert.ok(priv.includes(`${where}`), `privacy: ${where} fetches the account key and the page must name it`); } + for (const file of ['frontend/js/pricing-billing.js', 'frontend/js/dashboard-history.js']) { + assert.ok(!/fetch\(\s*['"`]\/api\/user\/account\/key/.test(stripJsComments(read(file))), + `${file} fetches the account key again; /privacy now says /pricing and /dashboard no longer hold it`); + } + assert.ok(priv.includes('no longer do.'), + 'privacy: the page must say the pricing and dashboard pages stopped holding the key'); // And nothing else in the frontend may fetch it without being named. This is // what catches a fourth page joining the list quietly. const fetchers = [];