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..6e41f837 100644
--- a/frontend/js/parashare.page.js
+++ b/frontend/js/parashare.page.js
@@ -10,6 +10,16 @@ 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 = '';
+// The receiver-pubkey poll lives next to the socket, and reopenSession has to
+// be able to stop it: a second poll on a dead token would keep asking forever.
+let pubkeyPoll = null;
+// nginx puts /api/user/ in the relay_auth zone (burst 5). One fetch for the
+// account key, and at most one retry, 2 s later, when that fetch is throttled.
+const KEY_RETRY_MS = 2000;
let sessionToken = '', ws = null;
let receiverPubs = null;
@@ -58,17 +68,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 +91,56 @@ 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.
+// The banner keeps its own words on purpose: failureText is the sentence for
+// what we did not plan for, and a key that will not load is planned, with a
+// better next step than "try again in a minute". Its unplanned tail goes
+// through failureText like everything else on this page.
+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;
+}
+
+// Step 2 used to answer a dropped socket with the bare word "Disconnected",
+// which tells the sender nothing about the file they just handed over. Same
+// treatment as the key banner: what happened, where the file is, one action.
+function setLinkError(on, msg) {
+ var box = $('ps-link-error');
+ if (!box) return;
+ if (on && msg) {
+ var t = $('ps-link-error-text');
+ if (t) t.textContent = msg;
+ }
+ box.classList.toggle('is-shown', !!on);
+}
+
+// Make a fresh session: new token, new link, new socket. The file is untouched
+// in the file input, so the sender does not pick it again.
+async function reopenSession() {
+ setLinkError(false);
+ if (pubkeyPoll) { clearInterval(pubkeyPoll); pubkeyPoll = null; }
+ if (ws) { try { ws.onclose = null; ws.close(); } catch (_) {} ws = null; }
+ receiverPubs = null;
+ var fp = $('fp-card');
+ if (fp) fp.style.display = 'none';
+ $('waiting-title').textContent = 'Waiting for receiver...';
+ $('waiting-dot').className = 'dot amber';
+ await createSession();
}
function setStatus(id, msg, cls) {
const el = $(id);
@@ -176,7 +238,8 @@ async function showReceiverConnected(kyberPub, ecdhPub) {
$('fp-display').textContent = fp;
$('waiting-title').textContent = 'Receiver connected';
$('waiting-dot').className = 'dot';
- setStatus('waiting-status', 'Receiver is waiting for you to verify the fingerprint');
+ setLinkError(false);
+ setStatus('waiting-status', 'Your receiver is waiting for you to compare the code');
setStepperStage('verify');
// TOFU: check if we've verified this fingerprint before
if (isFingerprintKnown(fp)) {
@@ -191,6 +254,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 +266,74 @@ 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('');
+ // An empty box has nothing wrong with it yet. Calling it invalid is what the
+ // "Change" link did the moment it cleared the field: a red-flavoured verdict
+ // on a field the user had not filled in.
+ if (!apiKey) {
+ setStatus('key-status', 'Enter your API key to continue');
+ keyValid = false; relayReady = false; relayError = ''; updateBtn(); return;
+ }
if (apiKey.length < 10 || !apiKey.startsWith('pgp_')) {
- setStatus('key-status', 'Invalid format');
- keyValid = false; updateBtn(); return;
+ setStatus('key-status', 'That does not look like a key. It starts with pgp_.', 'err');
+ keyValid = false; relayReady = false; relayError = ''; updateBtn(); return;
}
+ // A well-formed key that came from the session is usable now. Whether a
+ // sector answers is a separate question, and it is answered below without
+ // holding the button hostage.
+ keyValid = true; relayReady = false; relayError = '';
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;
@@ -244,7 +342,7 @@ function onFileSelect() {
setStatus('file-status', '✓ ' + files[0].name + ' (' + (files[0].size/1024/1024).toFixed(1) + ' MB)', 'ok');
$('vault-list').style.display = 'none';
} else {
- setStatus('file-status', '✓ ' + files.length + ' files selected — vault mode', 'ok');
+ setStatus('file-status', '✓ ' + files.length + ' files, sent as a single package', 'ok');
const vl = $('vault-list');
vl.style.display = 'block';
vl.innerHTML = [...files].map(f =>
@@ -261,6 +359,20 @@ 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) {
+ // relayError is the planned sentence. Reaching here without one is a state
+ // we did not plan for, so it gets the page's one sentence for that.
+ setCreateStatus(relayError || failureText('relay sector discovery', new Error('no sector answered')), 'err');
+ return;
+ }
+ }
+ setCreateStatus('');
// Generate random invite token
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
sessionToken = 'inv_' + u8toHex(tokenBytes).slice(0, 32);
@@ -270,6 +382,7 @@ async function createSession() {
const recvUrl = `${location.origin}/ontvang?s=${encodeURIComponent(sessionToken)}&r=${activeSector}`;
$('session-link').textContent = recvUrl;
+ setLinkError(false);
showStep('step-waiting');
connectWebSocket();
}
@@ -288,17 +401,19 @@ async function connectWebSocket() {
ws.onopen = () => {
// Join the invite room
ws.send(JSON.stringify({ type: 'join', room: sessionToken, nick: 'sender' }));
- setStatus('waiting-status', 'Waiting for receiver to open the link...');
+ setLinkError(false);
+ setStatus('waiting-status', 'Waiting for your receiver to open the link...');
};
// Poll voor receiver pubkey via ghost pipe relay
- let pubkeyPollInterval = setInterval(async () => {
+ if (pubkeyPoll) clearInterval(pubkeyPoll);
+ pubkeyPoll = setInterval(async () => {
try {
const r = await fetch(`${RELAY_API}/v2/pubkey/${encodeURIComponent(sessionToken)}`, { headers: { 'X-Api-Key': apiKey }, signal: AbortSignal.timeout(3000) });
if (r.ok) {
const d = await r.json();
if (d.kyber_pub && d.ecdh_pub) {
- clearInterval(pubkeyPollInterval);
+ clearInterval(pubkeyPoll); pubkeyPoll = null;
await showReceiverConnected(d.kyber_pub, d.ecdh_pub);
}
}
@@ -315,15 +430,26 @@ async function connectWebSocket() {
}
if (msg.type === 'peer_left') {
- setStatus('waiting-status', 'Receiver disconnected', 'err');
+ setStatus('waiting-status', 'Your receiver closed the link', 'err');
+ setLinkError(true, 'Your receiver closed the link before you compared the code. Nothing was uploaded and your file is still here in this browser.');
}
} catch(err) {
setStatus('waiting-status', failureText('receiver message', err), 'err');
}
};
- ws.onerror = () => setStatus('waiting-status', 'Connection error', 'err');
- ws.onclose = () => { if (!receiverPubs) setStatus('waiting-status', 'Disconnected', 'err'); };
+ // Both of these used to write one word into a status line. A sender who has
+ // just handed over a file needs to know that it did not go anywhere.
+ ws.onerror = () => {
+ if (receiverPubs) return;
+ setStatus('waiting-status', 'Connection lost', 'err');
+ setLinkError(true, 'The connection to the relay failed before your receiver arrived. Nothing was uploaded and your file is still here in this browser.');
+ };
+ ws.onclose = () => {
+ if (receiverPubs) return;
+ setStatus('waiting-status', 'Connection lost', 'err');
+ setLinkError(true, 'The connection to the relay dropped before your receiver arrived. Nothing was uploaded and your file is still here in this browser.');
+ };
}
// ── Fingerprint confirmed — encrypt & send ──
@@ -558,35 +684,61 @@ 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' });
+ }
+ // Expected, and marked so: a browser with no session gets 401/403, and a
+ // self-host that never built this endpoint answers 404. Both mean "no key
+ // here", the banner is the whole answer, and neither is worth a line in the
+ // console of every signed-out visitor.
+ if (!r.ok) {
+ const noSession = new Error('account key: HTTP ' + r.status);
+ noSession.expected = r.status === 401 || r.status === 403 || r.status === 404;
+ throw noSession;
+ }
+ const d = await r.json();
+ if (!d || !d.api_key) {
+ const noKey = new Error('account key: none on this session');
+ noKey.expected = true;
+ throw noKey;
+ }
+ return d.api_key;
+}
+
+async function loadAccountKey() {
+ try {
+ const key = await fetchAccountKey();
+ $('api-key').value = key;
+ applySlimApiKeyView();
+ await onKeyInput();
+ } catch (e) {
+ setKeyError(true);
+ // The banner carries the sentence either way. Only the unplanned half gets
+ // reported: a 500, or a fetch that never arrived. Reporting the planned half
+ // would put a console error on every signed-out page load, which is both
+ // noise and a false alarm for the heartbeat that reads that console.
+ if (!e || !e.expected) failureText('account key', e);
+ setStatus('key-status', 'Account key could not be loaded', 'err');
+ keyValid = false;
+ updateBtn();
+ }
+}
+
function toggleGlobe() {
const overlay = document.getElementById('globe-overlay');
const mainEl = document.querySelector('main');
@@ -869,3 +1021,4 @@ act('click','confirmFingerprint',()=>confirmFingerprint());act('click','copyLink
act('click','createSession',()=>createSession());act('click','expandApiKeyCard',()=>expandApiKeyCard());
act('click','rejectFingerprint',()=>rejectFingerprint());act('input','onKeyInput',()=>onKeyInput());
act('click','reload',()=>location.reload());
+act('click','reopenSession',()=>reopenSession());
diff --git a/frontend/parashare.html b/frontend/parashare.html
index d6bf27bd..3786025b 100644
--- a/frontend/parashare.html
+++ b/frontend/parashare.html
@@ -45,15 +45,65 @@
.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}
+
+/* One alert style, used by both places on this page where something can fail
+ in front of the user: the account key that will not load, and the relay
+ connection that drops while step 2 is waiting. Each says what happened, what
+ the file is doing meanwhile, and offers the one action that helps. */
+.ps-alert{display:none;flex-direction:column;align-items:flex-start;gap:10px;margin-bottom:16px;padding:12px 14px;border:1px solid var(--line-2);border-left:3px solid var(--sig-stop);border-radius:var(--r-1);background:var(--sig-stop-bg);font:13px/1.62 var(--sans);color:var(--ink-1)}
+.ps-alert.is-shown{display:flex}
+.ps-alert p{margin:0}
+.ps-alert-actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
+/* The primary action is the one that actually works on paramant.app: sign in
+ again. Typing a key by hand only helps a self-hosted relay, so it sits next
+ to it as a link with that said out loud. */
+.ps-alert-primary{display:inline-flex;align-items:center;min-height:var(--tap);padding:9px 16px;border:1px solid var(--accent);border-radius:var(--r-1);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans);text-decoration:none}
+.ps-alert-primary:hover{background:var(--accent-hov);border-color:var(--accent-hov)}
+.ps-alert-primary:focus-visible{outline:none;box-shadow:var(--ring)}
+.ps-alert-note{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--ink-3)}
+/* The note belongs to the secondary action and to nothing else, so it is
+ stacked under it rather than left to wrap under both buttons. */
+.ps-alert-secondary{display:inline-flex;flex-direction:column;align-items:flex-start;gap:0}
+.ps-alert .ps-key-change{padding-left:0;font-family:var(--mono)}
+
+/* Said before the sender starts, not discovered on step 2: this is a live
+ handshake and the other person has to be there for it. */
+.ps-live-note{margin:0 0 16px;padding:11px 14px;border:1px solid var(--line-2);border-left:3px solid var(--mark);border-radius:0 var(--r-1) var(--r-1) 0;background:var(--surface-sunk);font:14px/1.6 var(--sans);color:var(--ink-1)}
+
+/* The jargon lives here instead of in the flow. A sender who wants ML-KEM-768
+ can open it; a sender who wants to send a file never has to read it. */
+.ps-how{margin:0 0 18px;border:1px solid var(--line);border-radius:var(--r-1);background:var(--surface)}
+.ps-how summary{cursor:pointer;display:flex;align-items:center;min-height:var(--tap);padding:11px 14px;font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-2);list-style:none}
+.ps-how summary::-webkit-details-marker{display:none}
+.ps-how summary::after{content:" +";color:var(--ink-3)}
+.ps-how[open] summary::after{content:" \2013";color:var(--ink-3)}
+.ps-how summary:focus-visible{outline:none;box-shadow:var(--ring)}
+.ps-how-body{padding:0 14px 14px;font:13.5px/1.62 var(--sans);color:var(--ink-2)}
+.ps-how-body p{margin:0 0 10px}
+.ps-how-body p:last-child{margin-bottom:0}
/* design-system.css puts 32px of padding on every .card; here the padding
belongs to .card-head and .card-body, so it is reset. */
@@ -356,8 +406,8 @@
PARASEND
-
Encrypted file relay
-
End-to-end encrypted file transfer. The receiver's browser generates a keypair — you verify the fingerprint before encrypting. The relay never sees plaintext.
+
Send a file that deletes itself
+
Pick a file and send a one-time link. The file is locked in your own browser before it leaves, so nobody in between can open it, and that includes us.
@@ -375,7 +425,7 @@
Encrypted file relay
- 3 · Verify
+ 3 · Compare
@@ -389,16 +439,38 @@
Encrypted file relay
+
The person you send to has to be online while you send; you confirm a short code together.
+
+
+ How this works
+
+
Your receiver's browser makes a fresh pair of keys and sends us only the public half. Your browser locks the file for that public half and uploads the locked version. We hold no key, so what sits on our relay cannot be opened by us or by anyone who takes it from us.
+
The short code you compare is built from the receiver's public keys, and both browsers work it out on their own. If anything had swapped those keys along the way, the two codes would not match. That is why you read the code to each other on a channel we do not run, such as a phone call.
+
For the reader who wants the names: ML-KEM-768 and ECDH for the key exchange, AES-256-GCM for the file, ML-DSA-65 signatures per chunk. The locked file is stored with an expiry and is destroyed when the link runs out or once the download link has been spent.
+
+
+
Step 1 of 5 · Setup
- Pick a file, set how long the link lives. Your file stays in your browser until step 4. The relay never sees plaintext.
+ Choose a file and say how long the link should live. The file stays on your own computer until step 4. We never hold it in a form we could open.
-
- Using your account key pgp_...
+
+ Loading your account key
+
+
Your account key could not be loaded. Sign in again; if it keeps happening, mail privacy@paramant.app.
Pick more than one and they travel as a single package.
No file selected
@@ -428,20 +501,32 @@
Encrypted file relay
-
Relay permanently deletes blobs after expiry
+
When the link runs out we destroy the file, picked up or not.
+
+
Share this link
-
Send this link to the receiver. Their browser will generate a keypair. You'll see their fingerprint appear below — verify it with them before sending.
+
Send the link to your receiver any way you like. Keep this page open: the moment they open it, a short code appears here and the two of you compare it.
Step 2 of 5 · Share
- Send the one-time link via any channel. The link only tells the receiver where to connect; no key, no file, no metadata. When they open it, their browser generates an ML-KEM-768 keypair and we move to verify.
+ The link says where to connect, and nothing else. No key, no file, no file name. Your receiver has to open it while you are still on this page.
+
+
+
+
The connection to the relay dropped before your receiver arrived. Nothing was uploaded and your file is still here in this browser.
+
+
+ this makes a fresh link
+
@@ -453,29 +538,29 @@
Share this link
The link carries no key and no file name. It only says where to connect.
-
Share this link with the receiver
+
Send this link, and wait here for your receiver to open it
-
Receiver connected — verify fingerprint
+
Compare this code together
-
Call or message the receiver via a separate channel (phone, Signal, etc.) and ask: "What is your fingerprint?"
-
A compromised relay can return a fake key. Only fingerprint verification via a separate channel proves you are encrypting to the right person.
+
Call or message your receiver on a different channel (phone, Signal) and read the code below to each other. Both screens have to show the same one.
+
If the codes differ, stop here. A tampered relay can hand you the wrong key, and comparing the code on a channel we do not run is what catches it.
—
-
✓ Matches stored fingerprint from a previous session
-
◈ First contact with this receiver — verify carefully
+
✓ Same code as the last time you sent to this person
+
◈ First time with this receiver. Compare with extra care.
-
QR — receiver can scan to compare
+
Your receiver can scan this instead of reading it out