Email: privacy@paramant.app Subject: Security vulnerability — paramant-relay
We aim to respond within 48 hours and patch within 7 days for critical issues. All reports are treated with responsible disclosure.
We do not currently publish a PGP key. If your report is sensitive, email privacy@paramant.app and we will arrange an encrypted channel.
When a key is published it will be served at https://paramant.app/.well-known/openpgp-key.asc and its fingerprint listed here. Until then, treat any PGP key that claims to be ours as unverified and confirm through a separate channel before sending anything sensitive.
10 findings, all resolved.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | High | DOM XSS in ct-log.html — CT log entry fields and verify result rendered via innerHTML without escaping (CWE-79) |
Fixed: esc() applied to all API-sourced values in log entry renderer, verify result, and proof hash list |
| 2 | High | AES-256 key embedded in Thunderbird FileLink upload blob (CWE-320) | Fixed: key excluded from blob (packet v0x02), travels via URL fragment only — relay never holds decryption material |
| 3 | High | X-Forwarded-For: $proxy_add_x_forwarded_for in nginx — client-controlled header passed to backend (CWE-290) |
Fixed: both nginx-paramant-live.conf and nginx-selfhost.conf changed to $remote_addr; relay uses CF-Connecting-IP → X-Real-IP → socket via getClientIp() helper |
| 4 | Medium | decodeURIComponent() without try/catch crashes relay process on malformed % sequences (CWE-248) |
Fixed: all 4 routes (/v2/did, /v2/pubkey, /v2/fingerprint, /v2/attest) wrapped in try/catch → HTTP 400 |
| 5 | Medium | users.json read-modify-write race condition under concurrent key operations (CWE-362) |
Fixed: _mutateUsersJson() serialises all read-modify-write cycles inside a promise queue |
| 6 | Medium | /v2/sign-dpa unauthenticated and unthrottled (CWE-770) |
Fixed: per-IP (3/24 h) and per-email (1/24 h) in-process limits + nginx limit_req zone=api |
| 7 | Low | HTML injection in trial key email templates — name, email, useCase interpolated raw (CWE-116) |
Fixed: escHtml() helper applied to all user-supplied fields in welcomeHtml and notifyHtml |
| 8 | Low | drop.html QR fallback uses innerHTML with relay-returned URL (CWE-79) |
Fixed: replaced with document.createElement + textContent |
| 9 | Low | SDK pyproject.toml allows vulnerable requests and cryptography versions |
Fixed: floors raised to requests>=2.33, cryptography>=43.0.1, pytest>=9; requires-python bumped to >=3.10; requirements.txt lockfile generated |
| 10 | Low | Duplicate IP-derivation logic at 4 call sites in relay.js |
Fixed: consolidated into getClientIp() helper |
114 checks applied across 13 categories on paramant.app:
| Category | Result |
|---|---|
| Kernel module blacklist (33 modules) | Enforced |
| /tmp as tmpfs (nodev, nosuid, noexec) | Configured |
| AppArmor | 119/121 profiles enforcing |
| SSH hardening (MACs, LoginGraceTime, MaxStartups) | Applied |
| Kernel network hardening | Applied |
| PAM hardening (pwquality, faillock, pwhistory) | Applied |
| auditd | 49 CIS L2 rules loaded |
| AIDE | Installed, daily integrity check |
| Cron permissions | Restricted to root |
| Password policy | MAX_DAYS=365, SHA512 |
| sudo logging | Enabled with full I/O logging |
| Firewall | UFW/nftables, default deny |
| Login banners | Configured |
14 findings, all resolved in commit e6f216d.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | High | Admin login plain === + no rate limiting | Fixed: timingSafeEqual + per-IP rate limiter |
| 2 | Medium | safeEqual() bypassed on 3 relay paths | Fixed: all paths use safeEqual() |
| 3 | Medium | pgp_ enterprise admin path broken | Fixed: removed pgp_ admin support |
| 4 | Medium | Blob burned before transfer complete | Fixed: deferred deletion on res.finish() |
| 5 | Medium | TOTP timing-sensitive + code reuse | Fixed: full window scan + _usedTotpCodes |
| 6 | Medium | Sync file I/O on key create/revoke | Fixed: serialized async write queue |
| 7 | Medium | Relay registry unbounded + unpaginated | Fixed: cap + limit/offset pagination |
| 8 | Medium | CT log appendFileSync + no rotation | Fixed: async write stream + size rotation |
| 9 | Medium | Webhook SSRF port not restricted | Fixed: allowlist 443 + 80 only |
| 10 | Low | DID lookup O(n) scan | Fixed: O(1) via didRegistry.get(did) |
| 11 | Low | Admin login leaks internal address | Fixed: generic error, server-side log only |
| 12 | Low | Revoked keys keep WebSocket open | Fixed: ws.close(4401) on revoke |
| 13 | Low | Arbitrary plan strings accepted | Fixed: VALID_PLANS allowlist |
| 14 | Low | Invalid Base32 in TOTP_SECRET silent | Fixed: startup validation + clear error |
6 findings, all resolved in commit 0db3ef0.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | High | WebSocket proxy uses plain TCP to HTTPS upstream | Fixed: tls.connect() |
| 2 | High | stream-next returns synthetic hash not real blob hash | Fixed: per-device queue with real sha256 |
| 3 | High | Webhook SSRF — DNS not resolved before connecting | Fixed: dns.resolve + private range reject |
| 4 | Medium | SDK uses ?k= query param rejected by relay | Fixed: X-Api-Key header |
| 5 | Medium | pgp_ enterprise admin path broken end-to-end | Fixed: removed pgp_ support |
| 6 | Medium | Blob burned before transfer complete | Fixed: deferred deletion on res.finish() |
20 findings across 4 critical / 5 high / 6 medium / 5 low. Full report: docs/security-audit-2026-04.md
| # | Severity | Status |
|---|---|---|
| 1–3 | Critical | Fixed |
| 4 | Critical | In progress: plaintext filename in relay RAM |
| 5–9 | High | Fixed |
| 10–15 | Medium | Fixed (13: accepted — documented) |
| 16–20 | Low | Fixed |
| # | Severity | Finding | ETA |
|---|---|---|---|
| 4 | Critical | Plaintext filename stored in relay RAM | v2.4.6 |
| 14 | Medium | CT Merkle tree non-RFC-6962 compliant | v2.5.0 |
The security review of #397 accepted that /parashare had stopped keeping a key
in localStorage and then said the harder thing: the key should not be in the
browser at all. This records what was done about it and, more usefully, what is
still true afterwards.
/parashare fetched the account's API key from GET /api/user/account/key and
held it in a variable for the life of the tab. That key is a full data-plane
credential with no expiry and no scope. Anything that got to run script on
paramant.app could read it out of the page and keep it: upload and download on
the account, list and revoke its transfers, enrol a signing key, create ParaSign
envelopes, read the audit chain. Not for fifteen minutes. Until the owner
noticed and rotated the key, which is a thing an owner does when something has
already gone wrong.
The page's own hardening did not touch this. The key was never persisted and never logged; it was simply present, in a variable, which is all an injected script needs.
A pst_ session token, minted by the admin panel on behalf of a logged-in user
and handed to the browser instead of the key. Three properties, and the second
is the one that matters:
- Fifteen minutes. Held in the relay's shared Redis, so all five sectors honour the same token; not an operator knob, because a deployment that could set this to a week would have rebuilt the credential this removes.
- Five routes. An allowlist in
relay/lib/session-token.js, checked above every route comparison inrelay.js:/v2/check-key,POST /v2/ws-ticket,POST /v2/pubkey,GET /v2/pubkey/:device,POST /v2/inbound. Everything else is403, including/v2/user/*,/v2/outbound,/v2/audit,/v2/admin/*, the ParaSign envelope routes, and a second mint: a token cannot extend its own fifteen minutes. - The same account. Inside that scope the token authenticates as the api-key it was minted for, so quota, the audit chain and the tier ceilings resolve against the owner. A token is a narrower way to present an account, never a second account.
POST /v2/session-token needs X-Internal-Auth and a live X-Api-Key, so the
admin plane is the only caller and a browser can never name another account.
Revoking the key sweeps its tokens out of the store, and a token whose owner key
is inactive grants no principal even when that sweep did not run: the sweep is
the fast path, not the guarantee.
- The stored record names its owner by hash. It used to carry the api-key in
the clear. The key NAMES were already hashed, because SCAN output, keyspace
listings and the slowlog all show names, but the VALUE was not: an RDB
snapshot, a replica, a backup on a laptop or a
MONITORsession carried livepgp_credentials for every account that had sent a file in the last fifteen minutes. The record is now{kh, exp}, and the relay turns the hash back into a key by looking it up in the api-key table it already has in memory. Nothing derives a key from a hash; a read-only copy of the store is a copy of hashes. It also means key revocation and deletion arrive for free, because the lookup is against the live table. - The expiry is required, not optional. A record with no
exp, or one whoseexpis not a number, is refused. It used to fall through atypeofguard to whatever TTL redis happened to have on the key, which meant a credential whose lifetime was a property of the store alone, and no lifetime at all when the store was wrong. - A transfer made with a token is marked in the audit chain, with one field,
"via": "pst". Not a second identity: the chain is the owner's, keyed on the owner's api-key exactly as for a request that carried the key. Without the field an owner reading their own log cannot tell a transfer made from a browser session apart from one made with the key itself, which is the distinction that matters when they are working out what happened.
There is also a ceiling of 20 live tokens per account, answered with 429 and a
Retry-After. It is not a rate limit: the page mints one per load and one per
refresh, so twenty is far above honest use, and what it stops is a signed-in
session being run as a credential factory. Tokens already issued keep working,
and room returns as they expire; the sweep index is pruned of names redis has
already expired before a refusal is made, so nobody is refused on the strength
of tokens that are gone.
A script that runs on paramant.app can still act as the signed-in user for fifteen minutes. It can start a transfer, publish a handshake key and upload a blob against the account's monthly quota. What it can no longer do is take the key with it: it cannot read the account's downloads or audit log, cannot enrol a signing identity, cannot create or sign an envelope, and cannot do any of it after the token expires, because minting a new one requires the session cookie to still be there and the mint route to be reached through the admin panel.
That is a real reduction and it is not a fix for cross-site scripting. The CSP on the site and the escaping in the pages remain the thing that stops a script running in the first place; this only bounds what one gets if it does.
The GET /api/user/account/key reveal route existed and ParaSend was not its
only caller: /account, /pricing and /dashboard each fetched the raw key
into the browser and used it as a relay credential. The honest statement of the
ceiling at the time was that on /parashare the key was gone, and on a browser
that had loaded any of those three pages it was not.
That is now closed, and closing it needed a second purpose rather than a wider scope.
A token is minted FOR a purpose, and the purpose picks the allowlist.
relay/lib/session-token.js holds two lists. SCOPE is the ParaSend one and is
unchanged: the five transfer routes above. APP_SCOPE is the new one and holds
four routes, each because one page needed exactly it:
| Route | Page | Why |
|---|---|---|
POST /v2/billing/checkout |
/pricing |
pressing a price button creates the Mollie payment |
GET /v2/user/history |
/dashboard |
the account's own send/envelope history, read-only |
GET /v2/parasign/audit-export |
/dashboard |
the account's own signing audit, read-only, Business+ |
GET /v2/parasign/inbox |
/ signed in, /dashboard |
what is waiting for this account's signature, read-only |
GET /v2/parasign/inbox is in the list because of what it cannot hand out. It
answers with a document name, who sent it, when it went out and when signing
closes; it does not answer with the per-party invite token, the document hash or
the capsule, and the address it answers for is derived by the relay from the
resolved api-key rather than read from a header a browser could set. So a stolen
app token buys the knowledge that a document is waiting, and not the capability
to open or sign it, which still lives only in the link in the invitation mail.
POST /v2/parasign/inbox/:id/resend is deliberately in NO scope: it reads that
stored invite token back so the admin can put it in a mail, so it stays behind
X-Internal-Auth plus an asserted X-Verified-Email-Hash, where a browser
cannot reach it.
The two lists are disjoint. A token minted on /parashare is 403 on all
four app routes, and a token minted on /pricing is 403 on all five transfer
routes. Merging them into one flat allowlist would have widened the ParaSend
token by four routes to give three other pages a credential they needed, which
is how a narrow credential quietly becomes an api-key again. /v2/user/history
is named on its own path, never as a prefix, so the rest of /v2/user/* (the
signing-key and TOTP surface) stays shut under both purposes, as do /v2/keys,
/v2/outbound, /v2/audit, /v2/admin/* and a second mint.
The purpose is chosen by the ADMIN ROUTE, never by the caller:
POST /api/user/parasend/token asks for parasend and
POST /api/user/app/token asks for app, both ignore their request body, and a
purpose the relay does not recognise is refused at the mint with 400 unknown_purpose rather than folded onto a default. A stored record with no
purpose field predates this change and is a ParaSend token; a record carrying a
purpose the running build does not know authenticates nobody at all.
What each page does now.
| Page | File | Credential |
|---|---|---|
/account |
frontend/js/account.inline1.js |
the reveal route, and only when the "Advanced account key" fold is opened. Nothing is fetched or rendered on load |
/pricing |
frontend/js/pricing-billing.js |
Authorization: Bearer pst_..., purpose app, minted on the first click |
/dashboard |
frontend/js/dashboard-history.js |
the same, minted on the click that needs it. /dashboard no longer prints a key in any form, masked included |
tests/app-pages-no-api-key.test.mjs drives real Chromium over the three pages
and fails if any of them asks for the key on load, or renders anything shaped
like one.
What is left, stated plainly. The reveal route still exists and still
answers any signed-in browser with the raw key, because /account is the page
whose job is to show it to you and a self-hoster genuinely needs it. So a script
that runs on paramant.app with a session cookie can still ask for the key
directly. What changed is that it no longer finds one lying in a variable on a
page nobody opened for that reason, and that the two pages which used to put it
there now run on a credential that expires and opens four routes.
Two decisions came out of reviewing the site-claims work in #367, both about authentication, and both recorded here because the reasoning matters more than the diff.
/api/user/login incremented paramant:user:ratelimit:email:<email> before it
called findUserByEmail, refused at eleven inside fifteen minutes, and never
deleted the key on a successful sign-in. The address is request input. Eleven
posts spread over three IP addresses (the per-IP cap is five) put the owner of
that address on 429 for the full window, from any browser, with no way to
clear it. The relay carried the same shape one layer down: userMfaAttemptOk
counted attempts against a caller-supplied user_id and refused at ten.
The rule now: a counter keyed on an identity the attacker gets to name may impose cost on that identity, never denial.
| Layer | Keyed on | Counts | Over the threshold |
|---|---|---|---|
admin/lib/login-ratelimit.js |
IP address | attempts | refused, 429, 5 per 15 min |
admin/lib/login-ratelimit.js |
email (hashed) | failures only | proof-of-work, 428, after 10 |
relay/lib/auth-throttle.js |
user_id |
failures only | delay, 250 ms per failure, capped at 2 s |
A successful sign-in clears the per-account counters at both layers. The proof-of-work is the 2^18 challenge already used by signup and password reset; the relay layer has no client to run one, so it throttles instead. The request that asks for the proof is refunded to the per-IP counter, because it was never evaluated: charging for the quote as well as the answer would leave an honest user two real attempts out of five.
What the proof-of-work is worth, measured rather than assumed: a native solver on this repository finds a nonce in roughly 150 to 250 ms; a browser doing the same work through WebCrypto takes one to two seconds. So it prices automated guessing per attempt and it keeps a stranger from switching an account off. It is NOT the thing that stops guessing. That is the per-IP refusal, which is why that one stayed a refusal.
What was given up. There is no longer any per-account ceiling that refuses. A distributed attacker with many IP addresses can keep guessing one address indefinitely, at a couple of hundred milliseconds of CPU per attempt plus five attempts per IP per fifteen minutes. The per-IP limit is what makes that expensive; the proof-of-work only prices each attempt. Against a six-digit TOTP with a one-slot window under two algorithms (roughly six in a million per guess) it is a botnet-scale cost for a poor return, and it is the price of not handing every passer-by a way to switch off somebody else's account. If the trade needs revisiting, raise the difficulty for a hot address; do not reintroduce the refusal.
Both deadlines are configurable, PARAMANT_REDIS_DEADLINE_MS (relay.js) and
PARAMANT_TOTP_REPLAY_TIMEOUT_MS (lib/totp.js), default 1000 ms each, both
documented in deploy/.env.example. A value of zero or a non-number is ignored
and the default applies: an unbounded guard does not fail closed, it hangs.
The e-mail counter is hashed (paramant:user:loginfail:<sha256>), so the
rate-limit namespace no longer stores addresses in plaintext, and the namespace
is new, so no counter written under the old shape survives a deploy still
holding somebody out.
relay/lib/totp.js guarded replay with a per-slot SET NX, and swallowed a
store error with .catch(() => 'OK'). The comment above it called that a
deliberate choice: availability over replay protection, so a Redis blip could
not block a legitimate first use.
It is not defensible on this endpoint. The NX key is the only thing between an observed six-digit code and its replay inside the same 30-second slot, on the path that mints admin-panel sessions. And the availability it bought was imaginary: the session store is the same Redis, so a login that skips the replay check still cannot be issued a session.
A store failure now yields { valid: false, error: 'replay_store_unavailable' }.
/v2/user/verify-totp and both /v2/user/signing-key routes answer 503 and
log totp_replay_store_unavailable; the admin login passes the 503 through as
totp_unavailable rather than dressing it up as a wrong code, and does not
count it as a failed attempt. A refused replay is error: 'replay', a wrong code
carries no error at all, so the three cases stay distinguishable.
A store that never answers is an outage too. Fail-closed only means
something if a decision arrives. node-redis queues commands while it
reconnects, so against an unreachable server a call neither resolves nor
rejects. Measured on a booted relay with the server killed underneath it, the
verify path did not fail open OR closed: it hung, with no answer at all after
twelve seconds. Two bounded waits fix that on this path: the replay set
inside verifyTotpGeneric (storeTimeoutMs, 1s) and the secret read in front
of it (redisDeadline in relay.js, 1s). A timeout is reported exactly like a
thrown error, so the route answers 503 in about a second.
Closed by the 2026-09-03 change below. This was left open here as "a property of the relay's redis client, not of this path", with every other redis-backed route still inheriting it. It is now bounded on the client, in both services. See No redis call in either service can hang.
Pinned by relay/test/verify-totp.test.js (a throwing store, a synchronously
throwing store and a store that never answers, none of which may validate a
code), relay/test/route-user-mfa-lockout.test.js (a real relay, a real redis,
and the connection cut underneath it mid-suite), relay/test/auth-throttle.test.js
and admin/test/login-ratelimit.test.js.
Three findings from the review of #368.
This closes the finding #368 left open above, and it turned out to be two problems rather than one.
The offline queue. node-redis holds commands in memory while it reconnects,
and its default reconnect strategy retries for as long as the process lives.
Measured against redis 5.12.1 (relay) and 6.2.1 (admin), with a live connection
cut underneath the client: the first command after the cut rejects in about a
millisecond, and every command after that neither resolves nor rejects. Still
pending after four seconds, after twelve, after any bound worth measuring.
disableOfflineQueue turns that into an immediate refusal.
A connection that goes silent. If the bytes stop but the socket stays open
(a dropped firewall rule, a wedged proxy, a frozen container) nothing fails at
all. isReady stays true, the command goes out, the reply never comes.
disableOfflineQueue cannot see this case, because from the client's point of
view nothing is wrong. Only a per-command deadline catches it. Both, therefore,
and neither is redundant.
A bound makes the caller safe, not the client. A third measurement: after a
command is lost to a silent connection, node-redis goes on waiting for its reply
and holds every later command behind it, so the client never recovers even once
the network does. On both major versions, a connection that was holed and then
healed answered nothing again, ever, while continuing to report itself ready.
destroy() followed by connect() rebuilds it in about three milliseconds. The
guard does that after two unanswered commands in a row; not after one, because a
single slow command is a big SCAN or a loaded server, and tearing the socket
down for that would turn a slow minute into a broken one.
The bound lives on the CLIENT, in lib/redis-deadline.js, not on the call
sites: guardRedisClient returns a proxy that puts the deadline on every
command, every MULTI chain and every scanIterator step. There are roughly
300 redis call sites across relay.js, relay/lib/*, relay/envelope.js,
admin/server.js and admin/lib/*, including the sMembers/sRem pair in
consumeBackupCode that #368 bounded on the verify path and not on the
backup-code path next to it. A per-call-site list would have been wrong the
first time somebody added a route.
An exceeded bound, a closed socket and an offline client all arrive as one
RedisUnavailableError, so a route does not have to know which happened to know
that the honest answer is 503. Three places turn it into one:
relay.js redisOutage503 for the 31 route-level catches that used to answer 400
bad_request or 500 internal for an outage, a new top-level catch on the
relay's request handler for everything that does not catch (an async throw there
was previously an unhandled rejection: no answer at all, and on Node 22 a process
exit), and the admin's express error middleware.
PARAMANT_REDIS_DEADLINE_MS keeps its name from #368 and is now the single
configuration source for both services. Default 1000 ms; zero or a non-number
falls back to the default, because "wait forever" is the failure it exists to
prevent.
What was given up. During an outage every request pays the deadline once before it is refused, where before it paid nothing and answered nothing. A deployment whose redis routinely takes longer than a second to answer will see 503s it did not see before, and must raise the knob rather than remove it. There is no retry and no open circuit: throughput during an outage is not what this buys, a bounded answer is.
Honesty about the store. GET /health on the relay touches no redis and
still answers 200, which is correct: the process is up. GET /v2/health/deep
now carries a redis check and goes red when the store is unreachable, where it
previously reported the same green as a healthy relay. The admin had no health
route at all -- its container probe was GET /api/auth/check, which answers 401
when nobody is signed in, so "healthy" meant "the process still refuses me". It
now has GET /health, always 200, with status: "degraded" when redis cannot be
reached.
Pinned by relay/test/route-redis-outage.test.js and
admin/test/redis-outage.test.js (a booted relay and a booted admin, a real
redis behind a proxy the suite cuts and then black-holes mid-run, every
redis-backed route asserted to answer 503 inside the deadline, and both
processes asserted to heal by themselves when the store comes back),
relay/test/redis-deadline.test.js (the classifier, the deadline, the proxy and
the rebuild, without a redis) and tests/redis-deadline-parity.test.mjs (the
relay and admin copies of the module are one file, and both services actually
wrap their client with it).
POST /api/user/login did strictly more work for an address that exists, in two
separate ways, and the first round of this fix only closed the smaller one.
The cheap half: the work itself. An address with an account reached a second
relay call (/v2/user/verify-totp) and one more redis read, where one without
returned two steps earlier. About 4 ms with a realistic relay, with the two
distributions not overlapping. The status codes were already identical on
purpose (#368 folded three 403s into one 401 for exactly this reason); the clock
was not.
The expensive half: the throttle. relay/lib/auth-throttle.js delays a wrong
code by 250 ms per failure past ten, capped at two seconds, and relay.js
charges it on /v2/user/verify-totp before it checks anything. Only a request
naming an account that EXISTS ever reaches that sleep. So the anti-guessing
delay was itself the oracle, and it is three orders of magnitude louder than the
work difference. Twelve wrong codes from rotating source addresses put an
address there, and nothing refuses them, because the per-address counter
deliberately imposes cost rather than denial.
Measured on a booted admin with a stub relay charging the same throttle, 100 requests per case, interleaved, one source address each:
| prior failures | exists (p50) | absent (p50) | ranges |
|---|---|---|---|
| 0 | 251.87 ms | 251.90 ms | overlap |
| 12 | 509.91 ms | 251.61 ms | do not overlap |
| 20 | 2010.23 ms | 251.82 ms | do not overlap |
A third path leaked as well: 503 totp_unavailable, the answer the admin passes
through when the relay's replay store is down, was not floored and is only
reachable for an address that has an account. Nine milliseconds against two
hundred and fifty.
What replaces it. The delay has to be charged by something that does not
know whether the account exists. That is the admin, which owns a failure counter
keyed on the hashed ADDRESS and increments it for a miss exactly as for a hit.
loginRate.mirrorThrottleMs() reproduces the relay's curve on that counter, the
result is added to the floor under every credential answer on both login routes,
and the admin tells the relay it has already paid (throttled_upstream) so the
account-keyed delay is not charged a second time. The relay still counts the
failure, still reports what it would have charged as throttle_ms, and still
charges any caller that does not set the flag; the route is X-Internal-Auth
only, so the callers who can set it are callers who could already name any
account they like.
The 503 branch is floored like the rest. Same instrument, same 100 requests:
| prior failures | exists (p50) | absent (p50) | delta | ranges |
|---|---|---|---|---|
| 0 | 251.86 ms | 251.87 ms | -0.01 ms | overlap |
| 12 | 751.84 ms | 751.76 ms | 0.08 ms | overlap |
| 20 | 2251.90 ms | 2252.11 ms | -0.21 ms | overlap |
What was not padded, and why. 429 (per-IP refusal) and 428 (proof-of-work required) are not credential answers: their status codes tell them apart whatever the clock says, and holding the 428 back only delays the login page that is waiting to start hashing.
What this still does not fix. An address over the failure threshold answers 428 where one under it answers 401, so an attacker willing to burn ten failures per address can tell them apart by status code. That is the cost of pricing an attempt instead of refusing it, and it is a far more expensive oracle than a timing difference: ten failures and a 2^18 proof-of-work per address, against one unauthenticated request. It is a deliberate trade, not an oversight.
PARAMANT_LOGIN_MIN_ANSWER_MS (default 250 ms) is only the BASE of the floor;
the throttle is added on top, so a clean address is answered at 250 ms and one
with twenty failures at 2250 ms, either way regardless of whether it exists. The
base has to cover the work, which is about 10 ms against a healthy relay. An
answer that overruns its floor is logged as such, because at that point the
floor has stopped being one.
Pinned by admin/test/login-timing.test.js, which measures both cases at 0, 12
and 20 prior failures with the shipped default floor and the real throttle
values, asserts the medians do not separate and the ranges overlap, and pins the
throttled_upstream flag itself so a change that drops it cannot pass on a
quiet machine. Against the previous revision of this branch it fails on the
twelve-failure level. The instrument that produced the tables above is
admin/test/login-timing.bench.js; ADMIN_SERVER_JS= points it at any checkout.
admin/test/login-ratelimit.test.js drove lib/login-ratelimit.js directly
through an attemptLogin() helper written inside the test file, and then read
server.js as a string to assert the order of three calls. The module is
correct and the order assertion is worth keeping, but between them they never
ran the handler: the decision under test was one the test file made up, so it
could pass while the route was wrong.
admin/test/_admin-server.js is the admin counterpart of
relay/test/_relay-server.js: it spawns the real admin/server.js, points it
at a stub relay that answers the two routes a login touches, and speaks HTTP to
it. admin/test/login-http.test.js runs the reviewer's scenario on it -- ten
wrong codes on one address from three source addresses, then the owner solving
a real 2^18 proof-of-work and getting a session -- plus the per-IP refusal, the
IP refund on a priced attempt, and the relay 503 being passed through instead of
reported as a wrong code.
Checked against the code it is meant to catch: run against the pre-#368 admin,
three of its five tests fail, on the 429 where a 428 belongs and on the outage
reported as invalid_credentials. login-timing.test.js fails there too.
Found while reviewing the deadline in finding 1, and caused by it. Every limiter in both services was written as an INCR followed by a CONDITIONAL expiry:
const count = await redis.incr(k);
if (count === 1) await redis.expire(k, WINDOW_S); // only on the first hitThat is correct only while the two commands always happen together. The deadline makes the gap reachable in one request: if the INCR exceeds the deadline while the server still executes it, the caller gets an outage and the EXPIRE is never sent. The key then holds a count with TTL -1, and because the next INCR returns 2 rather than 1, no later call sets the expiry either. TTL -1 means for ever.
Measured on a booted admin with the replies from redis dropped for the duration
of one login: paramant:user:ratelimit:ip:<ip> stood at 9 with TTL -1, and that
source address kept getting 429 until the key was deleted by hand.
Three kinds of permanent damage, all of them denial of service produced by a redis hiccup in the code that exists to prevent denial of service:
paramant:user:ratelimit:ip:<ip>-- a permanent 429 for that source address;paramant:user:loginfail:<hash>-- a proof-of-work obligation that never lifts, on an address anybody may name;- the monthly counters in
relay/lib/quota.js-- an account permanently over its transfer or signing quota.
lib/redis-counter.js (incrInWindow) sets the expiry UNCONDITIONALLY after
every INCR, with NX so it can only ever create a window and never slide one.
The healthy case behaves exactly as before; the broken case is repaired by the
first request that lands after it, so a missing TTL survives one request instead
of for ever. All 18 INCR call sites in both services go through it:
admin/lib/login-ratelimit.js (2), admin/lib/webauthn.js (1),
admin/server.js (8), relay/lib/quota.js (6), relay/relay.js (1). (An
earlier revision of this note said 23, which counted the two copies of the
helper itself and miscounted admin/server.js.) EXPIRE ... NX needs Redis 7.0;
docker-compose.yml pins 7.4.8 by digest, and a server that refuses the option
makes the helper fall back to a TTL read for the life of the process, because an
error on every rate-limited route would be a worse regression than the bug.
Pinned by admin/test/ratelimit-ttl.test.js, which boots a real admin behind a
proxy that delivers commands and drops replies -- the shape that makes the
server execute the INCR while the client gives up on it -- and then reads the
TTL on its own connection. Against the previous revision of this branch both
counters come back TTL -1. A third test pins the NX: a later hit must not
extend a window that already exists, or the repair becomes the same denial of
service from the other end. tests/redis-deadline-parity.test.mjs fails if any
of the five files goes back to calling INCR directly.
The fixed floor from finding 2 was applied to /api/user/login-with-backup in
the same change, and it was not enough, because on that route the work is not
4 ms of redis reads. consumeBackupCode verifies the provided code against
EVERY stored hash until one matches, and a wrong code matches none, so a miss
costs ten full argon2id verifications at 64 MiB and timeCost 3. An address with
no account pays none of it.
Measured on the machine this was written on, with ten codes really stored:
| p50 | min | max | |
|---|---|---|---|
| one argon2 verification | 49.7 ms | 44.7 ms | 95.9 ms |
| ten of them (one wrong code) | 494.2 ms | 462.3 ms | 870.9 ms |
Through the route, with the 250 ms floor it had inherited: 472.7 ms for an
address that exists against 251.6 ms for one that does not, ranges not
overlapping. The admin logged answer overran its floor on 40 requests out of
40, which is the code saying out loud that the number it was given was not a
floor at all. That log line existed and nobody had run the route past it.
Two changes. The floor on this route is its own,
PARAMANT_LOGIN_BACKUP_MIN_ANSWER_MS, 1500 ms by default: 1.7x the slowest
ten-hash miss measured here and 3x the median. And the per-address throttle is
mirrored onto this route as well, on the counter it already keeps
(bk:email:<sha256>, incremented before the account lookup so it is the same
number for a hit and a miss). It needs its own curve rather than
mirrorThrottleMs: the route refuses at five attempts per address per window,
so the relay's threshold of ten is unreachable through it and a mirror using
that threshold would be zero for every attempt the route allows. It starts after
the first attempt instead, with the same 250 ms step and the same 2 s ceiling.
The route is the emergency path a user takes once, when their authenticator is gone, so a second and a half is a cost worth paying to stop it answering the question "is this address a customer". A slower machine needs a higher floor and will say so on every request that overruns it.
Pinned by admin/test/login-timing.test.js, which for this route boots a REAL
relay rather than the stub, enrols an account, checks that ten argon2 hashes are
really stored, and then measures a wrong code against an address that exists and
one that does not, at zero and four prior attempts (five prior attempts is a 429
for both, which is not a credential answer). Against the previous revision of
this branch it reads 491.99 ms against 253.16 ms with no overlap. The suite also
fails if the admin logged a single floor overrun.
A body that was not what it said it was answered 500 in a millisecond.
{"email": {}} is truthy, so if (!email) waved it through into
String(email).trim().toLowerCase(), which threw. Both login routes now check
the type and answer 400, which is the same answer for every caller and carries
no information about the address. It was also an unhandled throw on an
unauthenticated route.
The health routes repeated the deadline in their error text. The redis
deadline error carries the configured bound in its message ("no answer within
1000ms"), and both /health on the admin and the redis check in
/v2/health/deep passed it straight through, unauthenticated. They report a
fixed word now; the real message goes to the log.
Worth writing down because it is a property of the deployment rather than of the
code. If the RELAY's redis is unreachable while the ADMIN's is not, an address
with an account answers 503 totp_unavailable (the relay's replay guard failing
closed, passed through) while an address without one answers 401, because the
second never reaches the relay at all. The floor makes them take the same time;
the status codes still differ.
In the shipped topology this is not reachable: docker-compose.yml gives both
services the same redis, so the admin cannot serve a login at all while the
relay's store is down. It becomes reachable the moment somebody splits them, or
points the two at different instances of a cluster. Anyone doing that should
know that the split turns an outage into an enumeration oracle, which is why it
is recorded here rather than left as a surprise.
consumeBackupCode can spend a code while the caller sees 503. It is
SMEMBERS, then an argon2 verification per stored hash, then SREM. Each of those
is bounded separately now, so a deadline breach on the SREM leaves the code
consumed on the server while the admin answers 503 and the user is told the
service is down. The failure direction is safe (a code is burned, not accepted
twice) but it costs a legitimate user one of their backup codes for an outage
they did not cause. Fixing it properly means making the read-verify-remove
atomic, which is a Lua script or a WATCH/MULTI, and it belongs in its own change.
regenerateBackupCodes is a DEL followed by a SADD with nothing between
them. If the process dies, or the SADD exceeds its deadline, the account is
left with no backup codes at all and no error the user can act on. The same
transaction work covers both.
Both are reachable only through X-Internal-Auth routes and neither accepts a
code that should have been refused.
Scope: TOTP reset flow abuse protection, error response hardening, rate limit verification, email template security review.
| Area | Finding | Status |
|---|---|---|
| TOTP reset | Two-stage confirmation prevents enumeration + abuse | Implemented |
| Error responses | JSON-only on all endpoints; no HTML stack traces | Verified |
| Rate limits | All mutating endpoints rate-limited and audited | Verified |
| Email security | From-address, reply-to, List-Unsubscribe, masked IPs | Implemented |
| Integration tests | 24/24 passing after enterprise sprint | Passing |
Scope: Static code analysis, authentication flows, network and infrastructure, active penetration testing, cryptographic implementation review, business logic, load testing.
| Severity | Count | Status |
|---|---|---|
| Critical | 0 | — |
| High | 0 | — |
| Medium | 2 | Fixed in-session |
| Low | 1 | Fixed in-session |
| Informational | 11 | All passing |
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| A-01 | Medium | Setup token consumed by email scanner before user interaction | Fixed: setup.html gates init() behind explicit button click |
| A-02 | Medium | POST /v2/user/setup-totp returned 409 for provisional (unactivated) TOTP |
Fixed: endpoint is now idempotent; returns existing secret until activation |
| A-03 | Low | INTERNAL_AUTH_TOKEN absent from admin container environment |
Fixed: env var injected in docker-compose.yml |
| A-04 through A-15 | Info | Argon2id params, TOTP timing safety, AES-GCM nonces, PQ layer, setup token entropy, billing auth, email canonicalization, rate limits, container hardening, secrets hygiene | All passing |
Load test results (tool: hey, target: https://paramant.app/):
| Load | Requests | p95 latency | Errors | Container state |
|---|---|---|---|---|
| 10 rps | 100 | 10 ms | 0 | All healthy |
| 50 rps | 500 | 18 ms | 0 | All healthy |
| 100 rps | 1000 | 43 ms | 0 | All healthy |
| 500 rps | 5000 | 135 ms | 0 | All healthy |
Next audit: External third-party penetration test planned before public general availability.
| Severity | Finding | Status |
|---|---|---|
| High | Installer scripts emitted algorithm=SHA1 in otpauth:// URIs while relay.js verifies TOTP with HMAC-SHA256 (relay.js:746). TOTP codes generated by authenticator apps would fail silently on every login attempt. |
Fixed: install.sh and install-pi.sh updated to algorithm=SHA256 in both the URI and the manual-entry display string. |
Before Mission 4, tamper-evidence depended on trusting Paramant's own servers:
"Trust Paramant's servers"
After Mission 4, the trust model is:
"Trust that at least one relay operator is honest"
This is the same trust model as RFC 6962 Certificate Transparency. Any relay operator running paramant-verify-peers becomes an independent auditor.
Every change to the CT log produces a Signed Tree Head (STH) — an ML-DSA-65 signed commitment to the current Merkle root:
{
"version": 1,
"relay_id": "https://health.paramant.app",
"tree_size": 59,
"sha3_root": "deed04dd...",
"timestamp": 1713000000000,
"signature": "<base64 ML-DSA-65 over canonical JSON>"
}Signature is over the canonical JSON of {relay_id, sha3_root, timestamp, tree_size, version} (keys sorted). Signed with the relay's ML-DSA-65 identity key (NIST FIPS 204).
After every STH is produced, the relay broadcasts it to all registered peers:
POST /v2/sth/ingest
Body: { relay_id, sha3_root, timestamp, tree_size, version, signature, public_key, relay_pk_hash }
- Receiver verifies ML-DSA-65 signature before storing
- Invalid signatures are logged and rejected (HTTP 400)
- Valid STHs are stored in
data/peer-sths/{relay_pk_hash}.jsonl - Non-blocking, best-effort — peer failures do not affect the local relay
| Endpoint | Description |
|---|---|
GET /v2/sth/peers |
List all peer relays + their latest mirrored root |
GET /v2/sth/peers/:pk_hash |
Full STH history mirrored from a specific peer |
GET /v2/sth/consistency?from=N&to=M |
RFC 6962 consistency proof between two tree sizes |
GET /ct/feed.xml |
RSS feed of signed tree heads for external archiving |
GET /v2/sth/consistency?from=<old_size>&to=<new_size> returns an RFC 6962-style proof that the new tree contains the old tree as a prefix. This is the key property that prevents a relay from "rewinding" its log.
# Verify no entries were removed or reordered between size 10 and current
curl "https://health.paramant.app/v2/sth/consistency?from=10" | jq .Subscribe to /ct/feed.xml with any RSS reader to independently archive STH roots:
https://health.paramant.app/ct/feed.xml
If a relay later claims a different root for a published timestamp, any subscriber has cryptographic proof of the original commitment.
# Install
npm install -g @noble/post-quantum # required for ML-DSA-65
# Verify all peer STHs are consistent
paramant-verify-peers --relay https://health.paramant.app
# Exit 0 = all consistent (or 0 peers)
# Exit 1 = inconsistency detectedThe tool:
- Fetches the peer STH mirror from the local relay
- Verifies ML-DSA-65 signatures on each peer's latest STH
- Cross-checks by fetching the STH directly from the peer relay
- Checks for tree_size rollbacks (append-only violation)
- Reports inconsistencies with full details
Additional fixes applied 2026-04-13:
| Fix | Detail |
|---|---|
| .env permissions | chmod 600 |
| Stale debug process | Killed (API key was visible in ps aux) |
| SSH | PermitRootLogin prohibit-password, MaxAuthTries 3 |
| Spurious arm64 arch | Removed from apt |
| HSTS | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload on all 7 HTTPS server blocks (paramant.app + 5 relay subdomains). Applied directly in /etc/nginx/sites-enabled/paramant-public — the deploy-time nginx config (deploy/nginx-paramant-live.conf) also carries this header but the Cloudflare-facing config is gitignored. |
| TLS | ssl_protocols TLSv1.3 explicit on all 443 vhosts (see "TLS 1.3 only" below) + forward-secret cipher suite (ECDHE-*-GCM + CHACHA20-POLY1305) for the TLS 1.2 that is no longer offered; ssl_prefer_server_ciphers off (client chooses) |
| Google Fonts | Removed from CSP |
| atd | Stopped and disabled |
| NATS | Dedicated system user, systemd hardening |
| Docker | admin + relay containers non-root (since e6f216d) |
/dpa promises, in the article 28 agreement customers sign electronically:
"TLS 1.3 minimum on all relay endpoints". Every nginx config in this repository
allowed TLS 1.2 as well, and deploy/nginx/addin.paramant.app.conf named no
protocols at all and so inherited whatever the host default was. All nine
TLS-terminating server blocks now carry ssl_protocols TLSv1.3;:
| File | Server blocks |
|---|---|
deploy/nginx-paramant-public.conf |
6 (paramant.app + the five sector relays) |
deploy/nginx-selfhost.conf |
1 |
nginx-selfhost.conf |
1 |
deploy/nginx/addin.paramant.app.conf |
1 (was inheriting the default) |
deploy/nginx-paramant-live.conf is not in that list and does not terminate
TLS: it listens on 127.0.0.1:8080 behind Caddy, and the Caddy configuration
is not in this repository. Production paramant.app is therefore not covered
by this change; it needs the same setting applied to Caddy
(protocols tls1.3 tls1.3) before the /dpa line is true of the live edge.
This takes effect at the next nginx deploy, not at merge.
TLS 1.3 is RFC 8446, August 2018. What loses access:
| Client | Position |
|---|---|
| Chrome, Edge (Chromium) | 70+, October 2018. Fine. |
| Firefox | 63+, October 2018. Fine. |
| Safari, iOS | 12.1 / iOS 12.2, spring 2019. Fine. |
| Windows Schannel | TLS 1.3 only from Windows 11 and Server 2022. Windows 10 does not negotiate it in Schannel, so .NET HttpClient, WinHTTP and PowerShell Invoke-RestMethod on Windows 10 stop reaching the API. Browsers on Windows 10 are unaffected, because Chrome and Firefox carry their own TLS. |
| OpenSSL | 1.1.1+, September 2018. curl and Python requests on RHEL/CentOS 7 (OpenSSL 1.0.2) stop working, which is the platform most likely to be running an OT or DICOM integration. |
| Java | 11+, or 8u261+. Java 8 before u261 stops working. |
| Android | 10+ natively. Apps on Android 5 to 9 using HttpsURLConnection without Conscrypt stop working; Chrome on those devices is fine. |
| Outlook add-in | Runs in Edge WebView2 (Chromium). Fine. |
The two that matter commercially are Windows 10 API callers and RHEL 7 script callers. Neither can reach a browser-only workaround, so an SDK or IoT customer on either platform has to upgrade the platform, not the client library.
Historical record, kept as measured on that date. The base image has moved since: it is
node:24-alpine3.24, pinned by digest, as of 3.1.0.node:22andnode:25are both past their support window; see CHANGELOG.md.
- 0 npm vulnerabilities across all 4 packages
- Base image: node:22-alpine (node:20 was EOL)
- express 4.x → 5.x
- 0 GPL/AGPL/LGPL licenses
- 0 hardcoded secrets
What: gitleaks scan revealed historical credential exposure.
| Credential | Committed | Status |
|---|---|---|
RESEND_API_KEY (re_K1YQ…XvA) |
2026-04-01 in deploy/systemd/*.service (files since removed) |
Verified invalid via Resend API on 2026-04-15 |
3× demo API keys (pgp_…) |
2026-04-01 – 2026-04-07 in frontend/index.html, poc/README.md |
Revoked via /admin/ on 2026-04-15 |
Remediation: Server .env permissions hardened to 600; gitleaks pre-commit hook installed.
Note on git history: Historical commits still contain the now-invalid credentials. Rewriting history would break existing clones with no security benefit since the credentials are revoked. Documented here for transparency.
| Threat | Mitigation |
|---|---|
| Third-party storage provider reading file contents | Zero-knowledge relay: content encrypted client-side before transmission; relay never holds decryption keys |
| Network-level interception (MITM) | TLS 1.2/1.3 with forward-secret cipher suites; HSTS enforced on all subdomains |
| Harvest-now-decrypt-later (post-quantum adversary) | ML-KEM-768 key encapsulation inside TLS; classical TLS layer provides defense in depth |
| Credential stuffing | User accounts require TOTP; no stored passwords |
| Email link preview scanners consuming one-time tokens | Setup tokens are click-gated; endpoint is idempotent for provisional state |
| Log tampering (CT log) | SHA3-256 Merkle tree with ML-DSA-65 signed tree heads; gossip protocol between peer relays |
| Threat | Note |
|---|---|
| Endpoint compromise | If your device runs malware, client-side encryption offers no protection |
| Coerced disclosure | Legal orders directed at the operator can compel log disclosure; content remains encrypted |
| Social engineering of operators | Administrative access is protected by TOTP but not immune to targeted attacks |
| Quantum cryptanalysis of prior TLS sessions | Mitigated by the PQ encryption layer inside TLS, which is not retroactively breakable |
Paramant maps technical controls to NIS2 (EU 2022/2555), IEC 62443 (industrial control systems), and NEN 7510 (Dutch healthcare). Mapping documents are part of the standard delivery package for Enterprise customers.
What this means:
- Architecture aligns with the technical requirements of these frameworks (RAM-only storage, post-quantum key exchange, signed CT log, EU-only jurisdiction, no US CLOUD Act exposure).
- Compliance documentation is generated from operational evidence — CT log, deployment artefacts, configuration — and updated per release.
- A signed Data Processing Agreement under GDPR Art. 28 is available to all paid tiers.
What this does NOT mean:
- No external penetration test has been conducted as of the date of this document. The internal automated audit (2026-04-19) and the independent reviews by R. Zwarts and Ryan Williams listed above are not third-party certification.
- No certification body has audited Paramant against ISO 27001, SOC 2, or any of the frameworks mentioned. The mapping documents are operator-generated.
- "Compliant by design" describes architectural alignment, not formal attestation. Customers requiring independent attestation should treat Paramant as a component within their broader ISMS and contract their own auditor.
If you need formal certification or third-party attestation as part of your procurement process, talk to us early — we will work with your auditor and provide the evidence we have, but we will not represent the platform as pre-certified.
- Beta access only; hardening for general public availability is in progress.
- Billing in the bundled admin tool uses optional Stripe device-sync hooks (operator-configured); the hosted paramant.app service uses Mollie.
- Some accounts created before TOTP rollout operate on API-key-only authentication.
- Rate limits have not been validated under sustained adversarial load; no WAF is deployed.
- External third-party penetration test has not yet been conducted.
| Researcher | Contribution | Date |
|---|---|---|
| Ryan Williams (@scs-labrat) | Independent security review — 20 findings | April 2026 |
| R. Zwarts (@raymond-itsec) | Code audit — 20 findings across two reports | April 2026 |
| Hendrik Bruinsma (@readefries) | FileLink extension + bug reports | April 2026 |