Skip to content

ParaSend takes its API key from the session, and from nowhere else - #397

Merged
Apolloccrypt merged 4 commits into
mainfrom
parasend-session-key
Sep 3, 2026
Merged

ParaSend takes its API key from the session, and from nowhere else#397
Apolloccrypt merged 4 commits into
mainfrom
parasend-session-key

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Why

Buyer review: Send in the signed-in navigation ran dead on a hand-typed key.

/parashare opened on a card headed "Your API key" with an empty box in it, and the key the sender 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, the authoritative one, and
  • a localStorage entry (paramant_api_key) written by any earlier visit, on any account.

The second outlived the first. When the endpoint hiccuped, the page silently fell back to whatever was in the store, and a stale or hand-typed key sails past the pgp_ format check, fails at the relay, and leaves the sender with a button that will not light up and no reason on screen.

The re-review took that from 2 to 5 and left three things on the same branch, which are the second commit: the page still talked like the protocol it runs, and its two failure states said too little.

Commit 1: one source of key

parashare.page.js reads the account key from the session on every load and holds it in memory. No localStorage write, no localStorage read.

One fetch, one retry two seconds later, then it gives up. deploy/nginx-paramant-live.conf:147 puts /api/user/ in limit_req zone=relay_auth burst=5 nodelay, so a 429 here is the page arriving next to its own siblings rather than a broken account. Assumption, stated because it is not checkable from this repository: no limit_req_zone defines relay_auth anywhere in the tree (the self-host configs define their own zones, inbound, auth, api and so on). The rate behind the burst therefore lives in the server's own nginx.conf, outside version control. The retry is sized to the burst, which is in the repo; if the rate turns out to be far slower than assumed, one retry is still the right number and only the 2 s would want revisiting.

When it gives up, it says so. A banner states the failure, what to do, and where to write.

The class logic is the other way round. The slim key row is the default state of step 1; the manual card is the exception behind #step-setup.manual-key. The row is amber and reads "Loading your account key" until the key is really in.

keyValid no longer waits on discoverRelay, which used to fold two different failures into one null:

situation before after
a sector answers and refuses the key button disabled, "Invalid or revoked key" unchanged
not one of the four sectors answers button silently disabled button stays live; the failure is stated at Create secure session, with a retry

The truth on /privacy. The page said paramant_api_key was "Removed when you sign out" and nav-auth.js logout removed nothing. The key is off that list and off the row-28 inventory in docs/site-claims.md, and sign-out now clears one an older build left behind.

Commit 2: steps 1 and 2, written for the sender

The live handshake is said before it costs anything. A sender reached step 2, "WAITING FOR RECEIVER", before finding out the other person has to be at their screen right now, having already picked a file and made a link. One sentence now stands above step 1:

The person you send to has to be online while you send; you confirm a short code together.

It is pinned in tests/ui-truthfulness.test.mjs to the flow rather than to itself: both halves are read back out of parashare.page.js (createSession hands over to the waiting step; showStep('step-encrypting') has exactly one caller and it sits behind the receiverPubs guard), so the sentence cannot outlive the behaviour it describes.

Plain words, jargon one click away. The heading was "Encrypted file relay" and the lede explained the tool with keypair, fingerprint, plaintext and relay. The heading is now "Send a file that deletes itself". A "How this works" panel holds the mechanism and keeps the real names (ML-KEM-768, ECDH, AES-256-GCM, ML-DSA-65). The verify card and stepper stage 3 are named "Compare this code together" / "Compare". The file label reads "Choose a file (or several)".

The key banner points at the door that opens. "Use a key by hand" is no way out on the hosted relay, which mints the key the sender has never seen. Sign in again is the primary action; the manual card sits beside it under "for self-hosted relays".

Step 2 failures get the same treatment as the key banner. A dropped socket wrote the single word "Disconnected". It now says what happened, that nothing was uploaded and the file is still in this browser, and offers one button that makes a fresh session. A socket that closes after the receiver is known is the normal end of the handshake and raises nothing.

An empty key field is no longer called invalid. "Change" cleared the box and then told the user its format was wrong. Empty gets an invitation; a typed value that cannot be a key gets an error and looks like one.

Tests

tests/site-claims.test.mjs row 28 harvests get/set as storage and asserts a remove-only key is not named on the list: claiming 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.

tests/parasend-session-key.test.mjs runs parashare.page.js for real in a node:vm context with a hand-built DOM, a stubbed fetch and a drivable WebSocket, node builtins only, so it lands in the existing "Root integration suites" job. Ten cases: the three endpoint answers (200, 429-then-200, 500), the default markup state, a dead sector, sign-out cleanup, the banner's action order, the empty field, the dropped relay connection with its way back, and the plain-language rewrite.

Every case verified by sabotage in both directions, listed in each file's header: eleven sabotage runs across the two suites and the ui-truthfulness block, each one red on the case it targets, all ten green against the change and all six of the first batch red against origin/main.

Gates

first-screen, ui-truthfulness, site-claims, seo-contract, links, navigation-shell, frontend-loading-contract, frontend-module-scripts, cache-bust, csp-inline, static-sanity (including the commit-style guard, which does not run in CI), check-test-declarations, eslint, app-contrast, app-theme, apply-nav-idempotent: all green locally. The "How this works" summary was given a 44px target after app-contrast caught it at 39px.

(heartbeat-lib fails on this machine for a missing @noble/post-quantum in the linked node_modules; unrelated, and CI installs it.)

Screenshots, /parashare at 390, signed in

before after
key endpoint 200 "Your API key" card or a stale key the slim row, under a heading and a sentence a buyer can act on
key endpoint 429 falls back to localStorage, finds nothing, empty "Your API key" card retries after 2 s, slim row
key endpoint 500 empty card, no explanation banner, "Sign in again" first, manual key marked for self-hosters
socket drops on step 2 the word "Disconnected" what happened, where the file is, one button back

Commit 3: one failure voice, and a cache-bust that tells the truth

Rebased on #396. The failure sites this branch added go through failureText(), the helper #396 put at the top of parashare.page.js, so the page has one voice for the unplanned case instead of three. The account-key banner keeps its own sentence and reports the detail through the helper, because a key that will not load is a case we did plan for and "sign in again" beats "try again in a minute"; the same reasoning keeps the relay-sector sentence, and its fallback is now the shared one rather than a second wording invented at the call site.

parashare.page.js goes to ?v=5. #396 shipped a v4 of that file and commit 1 here shipped a different v4. Both sides agree on the number, so check-cache-bust cannot see it: one immutable URL, two contents, and whichever a browser cached first is the one it keeps. The script-tag order is pinned in the suite too, because error-message.js is a plain script and failureText reads window.paramantErrors at call time: load parashare.page.js first and the page quietly falls back to its own copy of the sentence. The vm suite now loads the real frontend/js/error-message.js into its context, so what it measures is the shipped sentence.

Commit 4: only the unplanned half is worth reporting

The heartbeat gate caught commit 3 within a minute, and it was right. tests/product-heartbeat.test.mjs loads /parashare from a static checkout with no admin server, so the key endpoint answers 404, and routing that through failureText put [paramant] account key Error: account key: HTTP 404 into the console of a page that was behaving correctly.

A 401 or 403 is a browser with no session; a 404 is a self-host that never built the endpoint; a 200 with no api_key is an account whose key is not revealable, which the reveal route documents. All three are answers, not faults. They are marked expected where they are thrown, and only a 5xx or a fetch that never arrived reaches the reporter. Case 11 measures both halves; the harness now runs error-message.js inside the vm context the way the page loads it, because requiring it from the test process closed it over the wrong console.

Note for the reviewer

nav-auth.js keeps its ?v= from main. That 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. The line added there is legacy cleanup; a delay of one cache generation costs nothing.

Apolloccrypt and others added 4 commits September 3, 2026 10:22
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.
Buyer re-review of the key work. The blockage is gone, and what is left is
that the screen still talks like the protocol it runs.

Say the live handshake before it costs anything. A sender reached step 2,
"WAITING FOR RECEIVER", before finding out that the other person has to be
at their screen right now. By then he had chosen a file and made a link.
One sentence now stands above step 1: the person you send to has to be
online while you send, and you confirm a short code together. It is pinned
in tests/ui-truthfulness.test.mjs to the flow rather than to itself: both
halves are read back out of parashare.page.js, so the sentence cannot
outlive the behaviour it describes.

Steps 1 and 2 in plain words. The heading was "Encrypted file relay" and
the lede explained the tool with keypair, fingerprint, plaintext and relay.
All four are accurate and none of them tells a small firm what the thing
does. The heading is now "Send a file that deletes itself". The jargon is
not deleted, it is moved: a "How this works" panel holds the mechanism and
keeps the real names, ML-KEM-768, ECDH, AES-256-GCM, ML-DSA-65, one click
away. The verify card is named after what it asks you to do, "Compare this
code together", and so is stage 3 of the stepper. The file label reads
"Choose a file (or several)" instead of announcing vault mode.

The key banner points at the door that opens. It offered one way out, "use
a key by hand", which on the hosted relay is no way out at all: the relay
mints the key and the sender has never seen it. Signing in again is the
primary action now, and the manual card sits beside it with "for
self-hosted relays" under it.

Step 2 failures get the same treatment as the key banner. A dropped socket
wrote the single word "Disconnected" into a status line, which answers none
of the three questions a sender has after handing over a file. It now says
what happened, that nothing was uploaded and the file is still in this
browser, and offers one button that makes a fresh session. A socket that
closes after the receiver is known is the normal end of the handshake and
raises nothing.

An empty key field is no longer called invalid. Pressing "Change" cleared
the box and then told the user its format was wrong. An empty box gets an
invitation; a typed value that cannot be a key gets an error, and looks
like one.

Four new cases in tests/parasend-session-key.test.mjs, all in function
scope against the real page script, each verified by sabotage in both
directions and listed in the file header. The "How this works" summary got
a 44px target so the coarse-pointer half of app-contrast stays green.
Follow-up to the security review, on top of #396.

The failure sites this branch added now go through failureText(), the
helper #396 put at the top of this file, so /parashare has one voice for
what we did not plan for instead of three. The account-key failure reports
through it and keeps its own banner sentence, because a key that will not
load is a case we DID plan for and "sign in again" is a better next step
than "try again in a minute". Same for the relay-sector sentence; what used
to be a second wording invented at the call site is now the shared one.

parashare.page.js goes to ?v=5. #396 shipped a v4 of that file and the
commit below this one shipped a different v4. Both sides agree on the
number, so check-cache-bust cannot see it: one immutable url, two contents,
and whichever a browser cached first is the one it keeps. The order of the
two script tags is pinned in the suite as well, because error-message.js is
a plain script and failureText reads window.paramantErrors at call time: put
parashare.page.js first and the page quietly falls back to its own copy of
the sentence. Verified by sabotage.

The vm suite now loads the real frontend/js/error-message.js into the
context, so what it measures is the shipped sentence and not the fallback.
The heartbeat gate caught this within a minute of the last commit, and it
was right. tests/product-heartbeat.test.mjs loads /parashare from a static
checkout with no admin server behind it, so GET /api/user/account/key
answers 404, and routing that through failureText put

  [paramant] account key Error: account key: HTTP 404

into the console of a page that was behaving correctly. The gate reads that
console and went red.

A 401 or 403 is a browser with no session. A 404 is a self-host that never
built the endpoint. A 200 with no api_key is an account whose key is not
revealable, which the reveal route documents in as many words. All three
are answers, not faults: the banner is the whole response to them, and a
console error on every signed-out page load is noise that also cries wolf
at the one gate watching for real ones. They are marked expected where they
are thrown, and only what is left, a 5xx or a fetch that never arrived,
goes to the reporter.

Case 11 of the suite measures both halves, and the harness now runs
frontend/js/error-message.js inside the vm context the way the page loads
it, as a plain script assigning self.paramantErrors. Requiring it from the
test process closed it over the wrong console and the 500 case could not
see its own log. Three sabotages, each red on the half it targets.
@Apolloccrypt
Apolloccrypt merged commit 31fe161 into main Sep 3, 2026
14 checks passed
@Apolloccrypt
Apolloccrypt deleted the parasend-session-key branch September 5, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants