From 6f39b2b2cd0d6ac9a92fdf2f1894397315ba4f67 Mon Sep 17 00:00:00 2001 From: Mick <103085050+Apolloccrypt@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:16:08 +0200 Subject: [PATCH 1/4] ParaSend takes its API key from the session, and from nowhere else A buyer review walked into this: Send in the signed-in navigation opened /parashare on a card headed "Your API key" with an empty box in it, and the key he typed there was not the key of the account he was signed in with. The page had two sources of truth. GET /api/user/account/key was one; a localStorage entry written by any earlier visit, on any account, was the other, and the second outlived the first. A stale or hand-typed key sails past the format check, fails at the relay, and all the sender sees is a button that will not light up. One source now. * parashare.page.js reads the account key from the session on every load and keeps it in memory. No localStorage write, no localStorage read. nginx puts /api/user/ in the relay_auth zone with burst 5, so a 429 is the page arriving next to its own siblings rather than a broken account: one fetch, one retry two seconds later, then it gives up. * When it gives up, it says so. A banner names the endpoint failure, what to do about it, and where to write if it keeps happening, and it keeps the manual card one click away for a self-host whose deployment has no such endpoint. * The slim key row is now the default state of step 1 and the manual card is the exception, which is the class logic the other way round from what shipped. The row is amber and says "loading" until the key is really in; it may not claim a key it does not have. * keyValid no longer waits on discoverRelay. A sector that answers and refuses the key is a verdict on the key and still disables the button; a sector that answers nothing at all is a network problem and now reports itself at "Create secure session", with a retry, instead of leaving a silent disabled button on screen. * /privacy listed paramant_api_key as "removed when you sign out" and nothing removed it. The key is gone from the list and from the row-28 inventory in docs/site-claims.md, and sign-out clears an old one that an earlier build left behind. Gates. site-claims row 28 now harvests get/set as storage and asserts a remove-only key is NOT on the list, because naming a key the browser no longer keeps is the same untruth as omitting one it does; it also pins by name that no frontend file writes paramant_api_key again. A new suite, tests/parasend-session-key.test.mjs, runs parashare.page.js for real in a vm context with a hand-built DOM and a stubbed fetch, node builtins only, and measures 200, 429-then-200 and 500 against the slim row, the button, the banner and the absence of any localStorage write. Every case was verified by sabotage in both directions, listed in the file header. nav-auth.js keeps its ?v=6: the version is shared by 52 pages and the cache-bust guard requires one version per asset, so bumping it here would collide with the navigation work in flight. The line added there is legacy cleanup whose delay by one cache generation costs nothing. --- docs/site-claims.md | 2 +- frontend/js/nav-auth.js | 1 + frontend/js/parashare.page.js | 177 +++++++++++++------ frontend/parashare.html | 53 +++++- frontend/privacy.html | 1 - tests/parasend-session-key.test.mjs | 253 ++++++++++++++++++++++++++++ tests/site-claims.test.mjs | 50 ++++-- 7 files changed, 465 insertions(+), 72 deletions(-) create mode 100644 tests/parasend-session-key.test.mjs diff --git a/docs/site-claims.md b/docs/site-claims.md index 622da03a..03eaeba2 100644 --- a/docs/site-claims.md +++ b/docs/site-claims.md @@ -56,7 +56,7 @@ Pages not in scope: everything behind a login or in a one-shot flow | 25 | The sub-processor lists are the parties the server code calls, and no page may deny the US one | privacy, dpa, press, architecture, sweep | Every `https.request({hostname})`, `fetch('https://…')` and `*_HOST` constant in `relay/*.js`, `relay/lib/*.js`, `admin/server.js`, `admin/lib/*.js`. That is `api.resend.com` and `api.mollie.com`. **Mollie was missing from the /dpa table** that customers sign while /privacy named it; added. Bunny.net (DNS, Slovenia) added to the /privacy list, which the rest of the site already named. Extended in the review round: /press and /architecture said "No US entity in the chain" while the signed /dpa table names Resend Inc. in the US under SCCs, so no page may make that denial while a US row stands | this PR | | 26 | Signing-envelope and delivery-receipt retention | privacy, dpa | `relay/envelope.js` `DEFAULT_TTL_DAYS = 30`, `MAX_TTL_DAYS = 365`; `relay/relay.js` `RECEIPT_TTL_MS = 15 * 60 * 1000`. The page said "until its expiry" for the first and nothing at all for the second; both now carry the number, and the signed /dpa sub-processor row carries the same two figures instead of "may persist until envelope expiry" | this PR | | 27 | The CT-log hash | privacy, dpa | `relay/lib/ct-hash.js` is SHA3-256 throughout. /dpa said SHA3-256, /privacy said SHA-256 in the same row; corrected | this PR | -| 28 | The browser-storage list | privacy | Every `localStorage` key the frontend writes, resolved through literals, constants and key-building helpers. The page listed `ps_free_uses` "to enforce the 10/day limit" and `pm_docs_key`, neither of which exists anywhere in the repository, and the limit it described is 10 a month; it did not list `paramant_api_key`, `paramant_status_v1`, `parasign.placement.tpl.v1`, `paramant_fp_…` or `paramant.keysetup.dismissed.v1`, which it does write. Pinned in both directions | this PR | +| 28 | The browser-storage list | privacy | Every `localStorage` key the frontend reads or writes, resolved through literals, constants and key-building helpers. The page listed `ps_free_uses` "to enforce the 10/day limit" and `pm_docs_key`, neither of which exists anywhere in the repository, and the limit it described is 10 a month; it did not list `paramant_status_v1`, `parasign.placement.tpl.v1`, `paramant_fp_…` or `paramant.keysetup.dismissed.v1`, which it does write. `paramant_api_key` was listed as "removed when you sign out" while nothing removed it; ParaSend has since stopped writing it altogether (`/parashare` reads `GET /api/user/account/key` on every load and keeps the key in memory), so the row is off the list and the two remaining mentions in the frontend are `removeItem` cleanup of what an older build stored. The harvest therefore counts get/set as storage and asserts a remove-only key is NOT named: claiming a key the browser no longer keeps is the same untruth as omitting one it does. Pinned in both directions, plus a named assertion that no frontend file writes `paramant_api_key` again | this PR | | 29 | What a self-hosted relay logs | security | `deploy/nginx-selfhost.conf` `log_format paramant_minimal` starts with `$remote_addr`. The IP-logging row described the hosted config only, so a self-hoster read "logging is off" about a config that logs addresses. Row extended | this PR | | 30 | One Hetzner location | dpa, privacy, README, sweep | The DPA is the document a customer relies on and says Nuremberg (NBG1); `README.md` said Frankfurt in the table that claims EU jurisdiction. Corrected to the DPA, and no file may name a second city. Which datacentre actually runs the service is still not provable from this repository | this PR | | 31 | What Mollie receives, and the billing stance | dpa, privacy, terms | `relay/relay.js` routes `/v2/billing/checkout` and `/v2/billing/webhook` and calls `mollie.createPayment` unconditionally, so the sub-processor row stays. The payload is amount, description, redirectUrl, webhookUrl and `metadata{accountId, product, plan, interval}`, and **no e-mail**: the only path that would send one is `ensureCustomer`, which returns `recurring_disabled` while `BILLING_MODE` is unset (`relay/lib/billing-recurring.js:123`, `relay/lib/mollie.js` `billingStance`). /dpa named an e-mail address it never sends; /privacy and /terms still said billing was not live and plans were arranged by hand. All three now describe one-off Mollie payments with no subscriptions, and the stance is asserted by calling `billingStance()` rather than by reading it | this PR | diff --git a/frontend/js/nav-auth.js b/frontend/js/nav-auth.js index 33814d8f..65347cb6 100644 --- a/frontend/js/nav-auth.js +++ b/frontend/js/nav-auth.js @@ -115,6 +115,7 @@ try { await fetch('/api/user/logout', { method: 'POST', credentials: 'include' }); } catch (err) {} + try { localStorage.removeItem('paramant_api_key'); } catch (err) {} // legacy: /parashare no longer writes it, clear an old one if (location.pathname === '/account' || location.pathname.startsWith('/auth/')) { location.href = '/'; } else { diff --git a/frontend/js/parashare.page.js b/frontend/js/parashare.page.js index fffb18b0..27e2cbf5 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 = []; +// 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. +let relayReady = false, relayError = ''; +// 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. +const KEY_RETRY_MS = 2000; let sessionToken = '', ws = null; let receiverPubs = null; @@ -58,17 +65,22 @@ function setStepperStage(key) { }); } -// Show the full API-key card (used by the "Change" link in the slim row) +// 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. function expandApiKeyCard() { var s = $('step-setup'); - if (s) s.classList.remove('has-saved-key'); + if (s) s.classList.add('manual-key'); + setKeyError(false); var inp = $('api-key'); if (inp) { inp.value = ''; inp.focus(); onKeyInput(); } + // Legacy only. This page no longer writes the key to localStorage; this + // clears whatever a build from before that change left behind. try { localStorage.removeItem('paramant_api_key'); } catch (_) {} } -// Apply slim API-key view when a key was auto-fetched (login flow) or -// saved locally. Pure cosmetic: the underlying input still holds the key. +// 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; @@ -76,9 +88,25 @@ function applySlimApiKeyView() { if (mask) { var v = inp.value; mask.textContent = v.length > 14 ? v.slice(0, 8) + '...' + v.slice(-4) : v; + mask.hidden = false; } + var label = $('ps-key-slim-label'); + if (label) label.textContent = 'Using your account key'; + var row = $('ps-key-slim'); + if (row) { row.classList.remove('is-loading'); row.hidden = false; } var s = $('step-setup'); - if (s) s.classList.add('has-saved-key'); + if (s) s.classList.remove('manual-key'); + setKeyError(false); +} + +// The banner. Shown only when the account key could not be loaded at all, and +// it takes the slim row with it: a row that says "using your account key" while +// there is no key would be the same lie the manual box used to tell. +function setKeyError(on) { + var box = $('ps-key-error'); + if (box) box.classList.toggle('is-shown', !!on); + var row = $('ps-key-slim'); + if (row && on) row.hidden = true; } function setStatus(id, msg, cls) { const el = $(id); @@ -191,6 +219,10 @@ async function showReceiverConnected(kyberPub, ecdhPub) { } // ── 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) { const results = await Promise.allSettled( Object.entries(RELAY_SECTORS).map(async ([sector, url]) => { @@ -199,43 +231,67 @@ async function discoverRelay(key) { signal: AbortSignal.timeout(5000) }); const d = await r.json(); - if (!d.valid) throw new Error('invalid'); - return { sector, url, plan: d.plan }; + return { sector, url, plan: d.plan, valid: !!d.valid }; }) ); - const valid = results.filter(r => r.status === 'fulfilled').map(r => r.value); - if (!valid.length) return null; - // Prefer health; otherwise first sector that responded - return valid.find(v => v.sector === 'health') || valid[0]; + const answered = results.filter(r => r.status === 'fulfilled').map(r => r.value); + const valid = answered.filter(a => a.valid); + return { + // Every sector that spoke said no. That is a verdict on the key. + rejected: answered.length > 0 && valid.length === 0, + // Prefer health; otherwise first sector that responded + found: valid.find(v => v.sector === 'health') || valid[0] || null + }; } // ── 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(); - if (apiKey) localStorage.setItem('paramant_api_key', apiKey); + setCreateStatus(''); if (apiKey.length < 10 || !apiKey.startsWith('pgp_')) { setStatus('key-status', 'Invalid format'); - keyValid = false; updateBtn(); return; + 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 = ''; setStatus('key-status', 'Checking...'); + updateBtn(); + let d; try { - const found = await discoverRelay(apiKey); - if (found) { - RELAY_API = found.url; - const sectorLabel = found.sector !== 'health' ? ` · ${found.sector}` : ''; - setStatus('key-status', `✓ Valid — plan: ${found.plan}${sectorLabel}`, 'ok'); - keyValid = true; - } else { - setStatus('key-status', 'Invalid or revoked key', 'err'); - keyValid = false; - } - } catch(e) { - setStatus('key-status', 'Could not verify key', 'err'); + d = await discoverRelay(apiKey); + } catch (e) { + d = { answered: false, rejected: false, found: null }; + } + if (d.found) { + RELAY_API = d.found.url; + relayReady = true; + 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'); keyValid = false; + } else { + // Nothing answered. Say so where the user is looking, and let the button + // stay live: the failure belongs at the press, with a reason attached. + relayError = 'No relay sector answered. Check your connection and press Create secure session again.'; + setStatus('key-status', 'Could not reach a relay sector. You can still continue.', 'err'); } updateBtn(); } +function setCreateStatus(msg, cls) { + const el = $('create-status'); + if (!el) return; + el.textContent = msg || ''; + el.className = 'status-line' + (cls ? ' ' + cls : ''); +} + function onFileSelect() { const files = $('file-input').files; selectedFile = files[0] || null; @@ -261,6 +317,18 @@ function updateBtn() { // ── Session creation ── async function createSession() { + // A sector that would not answer during discovery used to leave this button + // disabled with no explanation. Try once more here, and if it still will not + // answer, say so out loud instead of going quiet. + if (!relayReady) { + setCreateStatus('Looking for a relay sector...'); + await onKeyInput(); + if (!relayReady) { + setCreateStatus(relayError || 'No relay sector answered. Try again in a moment.', 'err'); + return; + } + } + setCreateStatus(''); // Generate random invite token const tokenBytes = crypto.getRandomValues(new Uint8Array(16)); sessionToken = 'inv_' + u8toHex(tokenBytes).slice(0, 32); @@ -558,35 +626,44 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Prefer the session-derived key: if the user is logged in, their current API key - // is the authoritative value. localStorage may still hold a stale key from a - // previous (revoked/rotated) account and would otherwise show "Invalid key". - (async function resolveKey(){ - try { - const r = await fetch('/api/user/account/key', { credentials: 'include' }); - if (r.ok) { - const d = await r.json(); - if (d && d.api_key) { - $('api-key').value = d.api_key; - try { localStorage.setItem('paramant_api_key', d.api_key); } catch {} - onKeyInput(); - applySlimApiKeyView(); - return; - } - } - } catch {} - // Not logged in or endpoint unavailable: fall back to localStorage (manual paste flow). - const saved = localStorage.getItem('paramant_api_key'); - if (saved) { - $('api-key').value = saved; - onKeyInput(); - applySlimApiKeyView(); - } - })(); + loadAccountKey(); // 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' }); + } + if (!r.ok) throw new Error('account key: HTTP ' + r.status); + const d = await r.json(); + if (!d || !d.api_key) throw new Error('account key: none on this session'); + return d.api_key; +} + +async function loadAccountKey() { + try { + const key = await fetchAccountKey(); + $('api-key').value = key; + applySlimApiKeyView(); + await onKeyInput(); + } catch (e) { + setKeyError(true); + setStatus('key-status', 'Account key could not be loaded', 'err'); + keyValid = false; + updateBtn(); + } +} + function toggleGlobe() { const overlay = document.getElementById('globe-overlay'); const mainEl = document.querySelector('main'); diff --git a/frontend/parashare.html b/frontend/parashare.html index d6bf27bd..8db66b93 100644 --- a/frontend/parashare.html +++ b/frontend/parashare.html @@ -45,15 +45,37 @@ .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} -/* Slim API-key row -- shown when key was auto-fetched from /api/user/account/key */ -.ps-key-slim{display:none;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border:1px solid var(--line);border-radius:var(--r-1);background:var(--surface-sunk);margin-bottom:16px;font-family:var(--mono);font-size:11px;color:var(--ink-3);flex-wrap:wrap} +/* The account key 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 + an empty "API key" box and typed one in by hand. #step-setup carries + .manual-key only when the user asks for the box (the Change link, or the way + out on the error banner), which is the self-host / paste-a-key path. */ +.ps-key-slim{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border:1px solid var(--line);border-radius:var(--r-1);background:var(--surface-sunk);margin-bottom:16px;font-family:var(--mono);font-size:11px;color:var(--ink-3);flex-wrap:wrap} +.ps-key-slim[hidden]{display:none} .ps-key-slim .ps-key-status{display:inline-flex;align-items:center;gap:8px} .ps-key-slim .ps-key-status::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--sig-done);flex-shrink:0} +/* Amber while the fetch is in flight: the row must not claim a key it does not + have yet. It turns green in applySlimApiKeyView, once the key is really in. */ +.ps-key-slim.is-loading .ps-key-status::before{background:var(--sig-wait)} .ps-key-slim .ps-key-mask{color:var(--ink-1);font-weight:600} -.ps-key-slim .ps-key-change{background:transparent;border:none;min-height:36px;padding:4px 6px;color:var(--accent);font:inherit;font-size:11px;cursor:pointer;text-decoration:underline;text-underline-offset:3px;letter-spacing:.04em} -.ps-key-slim .ps-key-change:hover{color:var(--accent-hov)} -#step-setup.has-saved-key .ps-key-card{display:none} -#step-setup.has-saved-key .ps-key-slim{display:flex} +.ps-key-mask[hidden]{display:none} +/* Not scoped to the slim row: the same control is the way out on the error + banner, and there it has to look like an action too. */ +.ps-key-change{background:transparent;border:none;min-height:36px;padding:4px 6px;color:var(--accent);font:inherit;font-size:11px;cursor:pointer;text-decoration:underline;text-underline-offset:3px;letter-spacing:.04em} +.ps-key-change:hover{color:var(--accent-hov)} +.ps-key-card{display:none} +#step-setup.manual-key .ps-key-card{display:block} +#step-setup.manual-key .ps-key-slim{display:none} + +/* The one thing that must never fail silently: no key, no ParaSend. The banner + says what happened, what to do, and leaves a door open for a self-hoster + whose deployment has no /api/user/account/key at all. */ +.ps-key-error{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)} +.ps-key-error.is-shown{display:flex} +.ps-key-error p{margin:0} +.ps-key-error .ps-key-change{padding-left:0;font-family:var(--mono)} /* design-system.css puts 32px of padding on every .card; here the padding belongs to .card-head and .card-body, so it is reset. */ @@ -394,11 +416,16 @@

Encrypted file relay

Pick a file, set how long the link lives. Your file stays in your browser until step 4. The relay never sees plaintext. -
- Using your account key pgp_... +
+ Loading your account key
+ +
Your API key
@@ -433,6 +460,10 @@

Encrypted file relay

+ +
@@ -609,7 +640,11 @@

Sent.

- + +