ParaSend takes its API key from the session, and from nowhere else - #397
Merged
Conversation
Apolloccrypt
force-pushed
the
parasend-session-key
branch
2 times, most recently
from
September 3, 2026 08:06
1e33850 to
13c92ef
Compare
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
force-pushed
the
parasend-session-key
branch
from
September 3, 2026 08:22
adb4220 to
5ca4ef8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andlocalStorageentry (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.jsreads the account key from the session on every load and holds it in memory. NolocalStoragewrite, nolocalStorageread.One fetch, one retry two seconds later, then it gives up.
deploy/nginx-paramant-live.conf:147puts/api/user/inlimit_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: nolimit_req_zonedefinesrelay_authanywhere in the tree (the self-host configs define their own zones,inbound,auth,apiand so on). The rate behind the burst therefore lives in the server's ownnginx.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.keyValidno longer waits ondiscoverRelay, which used to fold two different failures into onenull:The truth on /privacy. The page said
paramant_api_keywas "Removed when you sign out" andnav-auth.jslogout removed nothing. The key is off that list and off the row-28 inventory indocs/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:
It is pinned in
tests/ui-truthfulness.test.mjsto the flow rather than to itself: both halves are read back out ofparashare.page.js(createSessionhands over to the waiting step;showStep('step-encrypting')has exactly one caller and it sits behind thereceiverPubsguard), 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.mjsrow 28 harvestsget/setas 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 writesparamant_api_keyagain.tests/parasend-session-key.test.mjsrunsparashare.page.jsfor real in anode:vmcontext 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-libfails on this machine for a missing@noble/post-quantumin the linkednode_modules; unrelated, and CI installs it.)Screenshots, /parashare at 390, signed in
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 ofparashare.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.jsgoes to ?v=5. #396 shipped a v4 of that file and commit 1 here shipped a different v4. Both sides agree on the number, socheck-cache-bustcannot 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, becauseerror-message.jsis a plain script andfailureTextreadswindow.paramantErrorsat call time: loadparashare.page.jsfirst and the page quietly falls back to its own copy of the sentence. The vm suite now loads the realfrontend/js/error-message.jsinto 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.mjsloads /parashare from a static checkout with no admin server, so the key endpoint answers 404, and routing that throughfailureTextput[paramant] account key Error: account key: HTTP 404into 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_keyis an account whose key is not revealable, which the reveal route documents. All three are answers, not faults. They are markedexpectedwhere they are thrown, and only a 5xx or a fetch that never arrived reaches the reporter. Case 11 measures both halves; the harness now runserror-message.jsinside 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.jskeeps 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.