Skip to content

A bound on every redis call, a login that answers at a fixed time, and an admin test that boots the admin - #379

Merged
Apolloccrypt merged 4 commits into
mainfrom
fix/auth-hardening-2
Sep 3, 2026
Merged

A bound on every redis call, a login that answers at a fixed time, and an admin test that boots the admin#379
Apolloccrypt merged 4 commits into
mainfrom
fix/auth-hardening-2

Conversation

@Apolloccrypt

Copy link
Copy Markdown
Owner

Three findings from the security review of #368. Built on top of it, rebased onto main after it merged.

1. No redis call in either service can hang

#368 bounded one read on the TOTP verify path and recorded the rest in SECURITY.md as open: "every other redis-backed route in relay.js still inherits it and will still hang in an outage". Closing it turned out to need three things, not one.

The offline queue. node-redis holds commands in memory while it reconnects, and the default strategy retries for the life of the process. Measured against redis 5.12.1 (relay) and 6.2.1 (admin), with a live connection cut underneath the client:

first command after the cut every command after that
default rejects, ~1 ms still pending after 4 s, after 12 s, after anything
disableOfflineQueue: true rejects, ~1 ms rejects, ~0 ms

A connection that goes silent. Socket stays open, bytes stop. Nothing fails: isReady stays true, the command goes out, the reply never comes. disableOfflineQueue cannot see this, and it is the outage a dropped firewall rule or a wedged proxy actually produces. Measured on both versions, with disableOfflineQueue on: pending after five seconds, and after any bound worth measuring. Only a per-command deadline catches it. So both, and neither is redundant.

A bound makes the caller safe, not the client. Third measurement, and the one I did not expect: after a command is lost to a silent connection, node-redis keeps waiting for its reply and holds every later command behind it. Hole the connection, let one command be swallowed, then let the bytes flow again, and the client answers nothing, ever, while reporting itself ready. destroy() + connect() rebuilds it in about 3 ms. The guard does that after two unanswered commands in a row, not one, because a single slow command is a big SCAN or a loaded server and tearing the socket down for it would turn a slow minute into a broken one.

Where the bound lives. On the client, not on the call sites: lib/redis-deadline.js exports guardRedisClient, 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/*, envelope.js, admin/server.js and admin/lib/* -- including the sMembers/sRem pair in consumeBackupCode behind /v2/user/consume-backup, which #368 bounded on the route next to it and not on this one. A per-call-site list is wrong the first time somebody adds a route.

An exceeded deadline, a closed socket and an offline client all arrive as one RedisUnavailableError, and three places turn it into 503:

  • redisOutage503 in relay.js, one line at the top of 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. That is a fix in its own right: http.createServer was handed a 4000-line async callback with nothing behind it, so a throw was an unhandled rejection, which is no answer to the client and a process exit on Node 22. It was survivable only because a dead redis hung instead of throwing;
  • the admin's express error middleware, which answered 500 for everything.

PARAMANT_REDIS_DEADLINE_MS keeps the name #368 gave it and is now the single configuration source for both services.

The health routes were part of the same blind spot. /v2/health/deep listed eight checks and never mentioned the store that holds every TOTP secret, so it reported the same green as a healthy relay. It carries a redis check now. The admin had no health route at all: its container probe is 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. The relay's own /health touches no redis and still answers 200, which is correct.

Sabotage evidence. relay/test/route-redis-outage.test.js and admin/test/redis-outage.test.js boot a real relay and a real admin against a real redis behind a TCP proxy the suite owns, then break it two ways. Deadline set to 400 ms for the suites, which also pins the knob.

route-redis-outage (cut, then black hole), each route asserted 503 and under 3 s:
  auth: POST /v2/user/setup-totp
  auth: POST /v2/user/verify-totp
  auth: POST /v2/user/consume-backup      <- the one #368 left open
  auth: POST /v2/user/get-totp-provisional
  envelope: POST /v2/envelopes
  envelope: GET /v2/envelopes/:id
  GET /health                 -> 200
  GET /v2/health/deep         -> 200, redis check red, overall not green
  and the relay heals by itself when the bytes flow again

admin-redis-outage (cut, then black hole):
  POST /api/user/login        -> 503
  GET /api/user/me            -> 503
  GET /api/user/session/verify-> 503
  GET /api/captcha/challenge  -> 503
  GET /health                 -> 200, status degraded
  and the admin heals by itself, no restart

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 must raise the knob rather than remove it. There is no retry and no open circuit: this buys a bounded answer, not throughput during an outage.

2. The login page told you which addresses were customers

POST /api/user/login did strictly more work for an address that exists: a second relay call (/v2/user/verify-totp) and one more redis read. The status codes were already identical on purpose -- #368 folded three 403s into one 401 for exactly this reason -- but the clock was not.

Measured on a booted admin, 200 requests per case, interleaved, one source address each, admin/test/login-timing.bench.js. The stub relay is given a cost for the verify call, because the size of the oracle is exactly the cost of that call.

Before (ADMIN_SERVER_JS pointed at the merged main):

relay verify cost case mean p50 p90 p99 min max
0 ms exists 2.52 2.31 3.62 5.16 1.37 5.49
0 ms absent 1.63 1.48 2.42 3.17 0.84 3.52
delta of medians 0.83
3 ms exists 6.25 6.44 7.30 9.24 4.59 9.94
3 ms absent 2.09 2.20 2.80 3.28 0.90 3.73
delta of medians 4.24

In the 3 ms row the distributions do not overlap: the fastest hit (4.59 ms) is slower than the slowest miss (3.73 ms). One request classifies an address, under the rate limit, with no credentials. The review measured 5.40 against 2.95 ms on the production pair; same channel, slower machine here.

After, same instrument, same 200 requests:

relay verify cost case mean p50 p90 p99 min max
0 ms exists 251.35 251.34 252.35 252.93 249.67 253.20
0 ms absent 251.24 251.28 251.98 252.60 249.61 252.94
delta of medians 0.06
3 ms exists 251.34 251.37 252.35 252.93 249.61 253.01
3 ms absent 251.27 251.27 252.22 252.98 249.70 253.00
delta of medians 0.11

No credential answer leaves before t0 + PARAMANT_LOGIN_MIN_ANSWER_MS (default 250 ms), so what an attacker times is a constant this code sets. The not-found branch also makes the same number of redis calls as the found one, so the floor is a margin rather than the only thing holding the two paths together. A fixed added delay would not have worked: it moves both curves and keeps the distance between them.

The 429 and the 428 are deliberately not padded. They are not credential answers, their status codes tell them apart whatever the clock says, and holding the 428 back would only delay the login page that is waiting to start hashing.

What this does not fix, said out loud: 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 still tell them apart by status code. That is the price of pricing an attempt rather than refusing it, and it is a far more expensive oracle than 4 ms.

admin/test/login-timing.test.js measures both cases against a booted admin and asserts the medians do not separate and the ranges overlap. Against the pre-fix admin it fails with exists p50=7.86 [6.62, 12.40], absent p50=3.22 [1.81, 6.19].

3. A test that reimplemented the handler it was testing

admin/test/login-ratelimit.test.js drove lib/login-ratelimit.js through an attemptLogin() helper written inside the test file, then read server.js as a string to assert the order of three calls. Both halves are worth keeping. Neither ever ran the handler, so the suite could pass while the route was wrong, which is the reviewer's objection.

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 answering the two routes a login touches, and speaks HTTP to it. Redis is real; the admin process is real and unmodified; only the relay is stubbed, and it can be made to answer 503 on demand.

admin/test/login-http.test.js runs the reviewer's scenario on that:

  • ten wrong codes on one address from three source addresses, all 401, no 429;
  • the owner then meets a 428, solves a real 2^18 proof-of-work from GET /api/captcha/challenge, and gets a session with a cookie;
  • the next sign-in is clean, so a success really clears the score;
  • the per-IP refusal still fires on the sixth attempt and follows the caller, not the address;
  • a priced attempt refunds the IP, so nine quotes in a row stay 428 rather than turning into a 429;
  • a relay 503 is passed through as totp_unavailable and is not counted against the address.

Checked against the code it is meant to catch. Run against the pre-#368 admin (b913f9e0), three of its five tests fail:

not ok 1 - ten wrong codes from three addresses do not lock the owner out
  error: 'past the threshold the answer is 428, got 429 {"error":"rate_limited"}'
not ok 3 - a priced attempt hands the IP its try back, so a quote costs nothing
  error: 'quote 1 must stay a 428, got 429 {"error":"rate_limited"}'
not ok 4 - a relay that cannot reach its replay store is reported as an outage, not a wrong code
  error: 'an outage must be passed through: 401 {"error":"invalid_credentials"}'

The admin CI job gets a redis service and npm ci. It installed nothing until now, which is the reason every admin suite was a lib test or a source-text assertion in the first place, and it now fails on a silent suite the way the relay jobs do.

Test runs

suite before after
tests/static-sanity.sh PASS, 11 checks PASS, 11 checks
npx eslint@9 . clean clean
relay unit 185 199 pass, 0 fail
admin unit 40 58 pass, 0 fail
root integration 152 166 pass (2 skipped, the external-link checks), 0 fail
relay route suites 82 85 pass, 0 fail
relay boot suites 11 11 pass, 0 fail

Notes for the reviewer

  • The module exists twice, relay/lib/redis-deadline.js and admin/lib/redis-deadline.js, byte-identical. The two services are separate npm projects on different major versions of the redis client and each Dockerfile copies only its own lib/, so a shared module one directory up would resolve in a checkout and be missing in the container. tests/redis-deadline-parity.test.mjs fails if the copies drift and also asserts both services really wrap their client.
  • relay/test/auth-throttle.test.js had two source assertions pinning PARAMANT_REDIS_DEADLINE_MS and the fallback rule inside relay.js. The declaration moved into the module, so the pin follows it; the property asserted is unchanged.
  • The deadline timer is deliberately not unref'd, and there is a comment saying why. It was, briefly, and relay/test/redis-deadline.test.js caught it: a deadline that does not hold the event loop open does not fire when nothing else is running, which reintroduces the exact hang inside the mechanism meant to prevent it.
  • New env knobs are documented in deploy/.env.example with read in: pointers, which tests/env-documented.test.mjs verifies.
  • admin/test/login-timing.bench.js is the instrument, not a test. ADMIN_SERVER_JS=/path/to/checkout/admin/server.js node admin/test/login-timing.bench.js reproduces every number in section 2.

@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Herstelronde 2: beide blokkers dicht, met meetbewijs

Rebased op origin/main (nu op 4e6de0b0), tweede commit op dezelfde branch: add9b894.

Blokker 1 - een teller die zijn tijdvak kwijtraakt, weigert voorgoed

Bevestigd en breder dan gemeld: 23 call-sites, niet 14. Alle 23 lopen nu via lib/redis-counter.js:

async function incrInWindow(client, key, windowSec) {
  const count = await client.incr(key);
  await ensureWindow(client, key, windowSec);   // EXPIRE ... NX, onvoorwaardelijk
  return count;
}

admin/lib/login-ratelimit.js (2), admin/lib/webauthn.js (1), admin/server.js (7), relay/lib/quota.js (6), relay/relay.js (1) plus de twee kopieën van de helper zelf. NX en niet gewoon EXPIRE: onvoorwaardelijk verlengen zou het tijdvak laten meeschuiven, en dan houdt een aanhoudende beller zijn eigen weigering eeuwig in leven. Dezelfde denial of service, andere kant op.

EXPIRE ... NX vraagt Redis 7.0. docker-compose.yml pint 7.4.8 op digest, maar een server die de optie weigert zet de helper voor de rest van het proces op de TTL-lees-variant, want een fout op élke rate-limited route zou erger zijn dan de bug.

Sabotage. admin/test/ratelimit-ttl.test.js boot een echte admin achter een proxy die commando's dóórlaat en antwoorden weggooit. Dat is de enige vorm die de bug reproduceert: een volledige storing voert de INCR ook niet uit, en dan is er niets om te stranden.

Tegen de vorige revisie (46939738):

not ok 1 - a counter whose INCR outlived the deadline still gets an expiry
  error: 'the per-IP counter must carry a window, got TTL -1 (-1 means this address is refused for ever)'
not ok 2 - the same for the failure counter behind the proof-of-work threshold
  error: 'the failure counter must carry a window, got TTL -1 (-1 is a proof-of-work bill that never lifts)'

Tegen deze revisie: 3/3 groen. De derde test pint de NX: een latere treffer mag een bestaand tijdvak niet oprekken. tests/redis-deadline-parity.test.mjs gaat rood zodra een van de vijf bestanden weer rechtstreeks INCR aanroept, of alleen op de eerste treffer een expiry zet.

Blokker 2 - de vloer dichtte het orakel niet

Terecht, en het gemiste deel is drie ordes van grootte breder dan het deel dat wél dicht was. Ik heb de stub-relay de échte throttle uit relay/lib/auth-throttle.js laten rekenen, zodat de meting het pad meet dat productie loopt.

Vóór (ADMIN_SERVER_JS op 46939738), 100 requests per geval, geïnterleaved, één X-Real-IP per request, vaste relay-kost 3 ms:

eerdere mislukkingen bestaand p50 afwezig p50 delta ranges
0 251.87 ms 251.90 ms -0.03 overlappen
12 509.91 ms 251.61 ms 258.30 overlappen niet
20 2010.23 ms 251.82 ms 1758.41 overlappen niet

, zelfde instrument, zelfde 100 requests:

eerdere mislukkingen bestaand p50 afwezig p50 delta ranges
0 251.86 ms 251.87 ms -0.01 overlappen
12 751.84 ms 751.76 ms 0.08 overlappen
20 2251.90 ms 2252.11 ms -0.21 overlappen

De vertraging is verhuisd naar de enige plek die niet weet of het account bestaat. loginRate.mirrorThrottleMs() rekent dezelfde curve (10 / 250 ms / 2000 ms) op de per-adres-mislukkingsteller, die een misser precies zo telt als een treffer, en het resultaat gaat bij de vloer op. De admin zegt er tegen de relay bij dat er al betaald is (throttled_upstream), zodat de account-gekoppelde vertraging er niet nog eens overheen komt; de relay telt de mislukking nog steeds, rapporteert nog steeds wat hij zou hebben gerekend (throttle_ms), en rekent hem nog steeds aan iedere beller die de vlag niet zet. De route zit achter X-Internal-Auth, dus wie de vlag kan zetten kon al elk user_id opgeven dat hij wilde.

  • de 503 totp_unavailable-tak is nu ook gevloerd (was 9 ms tegen 252 ms, en alleen bereikbaar voor een bestaand adres);
  • /api/user/login-with-backup had dezelfde vorm en is meegenomen;
  • login-timing.test.js draait op de verscheepte default-vloer (250 ms) en de echte throttle-waarden, op 0, 12 en 20 eerdere mislukkingen, en betaalt boven de drempel een echte 2^18 proof-of-work (vóór de klok start, anders meet je het hashen). Het pint ook de vlag zelf, zodat een wijziging die hem laat vallen niet op een rustige machine kan doorglippen.

Tegen 46939738:

not ok 1 - the refusal takes the same time whether the address has an account or not
  error: 'exists must be held to its floor of 750ms; at 12 prior failures:
          exists p50=510.76 [510.18, 520.27], absent p50=252.59 [250.73, 253.58]'
not ok 2 - the relay is told the delay was already charged, so it is never charged twice
  error: 'every verify-totp call must declare the throttle was applied upstream, 20 did not'

Wat níét dicht is, en in SECURITY.md staat: een adres boven de drempel antwoordt 428 waar een adres eronder 401 antwoordt. Dat blijft een onderscheider voor wie tien mislukkingen plus een proof-of-work per adres wil betalen. Dat is de prijs van beprijzen in plaats van weigeren, en het is een bewuste ruil, geen omissie.

Nits

  • /health geeft geen deadline-getal meer prijs. redisHealthy() geeft een vast woord terug (reachable / not connected / unreachable); de echte fout gaat naar de log. Idem voor de redis-check in /v2/health/deep, die in full mode publiek is.
  • De .env.example-regel zegt nu wat de default dekt (het werk, ~10 ms) en wat er bovenop komt (de throttle), in plaats van te suggereren dat 250 ms het traagste echte pad afdekt. Een antwoord dat zijn vloer overschrijdt wordt gelogd, want dan ís het geen vloer meer.

Vervolg, vastgelegd in SECURITY.md

  • consumeBackupCode kan een code verbruiken terwijl de beller 503 ziet: SMEMBERS, argon2 per hash, SREM zijn drie los begrensde aanroepen. Richting is veilig (een code wordt verbrand, niet twee keer geaccepteerd), maar het kost een eerlijke gebruiker een backup-code voor een storing die hij niet veroorzaakte.
  • regenerateBackupCodes is DEL gevolgd door SADD zonder transactie.

Beide vragen hetzelfde werk (Lua of WATCH/MULTI) en horen in een eigen wijziging.

Suites

suite vorige revisie nu
tests/static-sanity.sh PASS, 11 checks PASS, 11 checks, 127 suites
npx eslint@9 . schoon schoon
relay unit 199 199 pass, 0 fail
relay route (echte redis) 85 91 pass, 0 fail
admin (echte redis) 58 63 pass, 0 fail
root integration 166 176 pass (2 skipped), 0 fail
relay boot 11 11 pass, 0 fail

De drie sabotage-suites ruimen nu op via t.after(), zodat een rode run afsluit in plaats van te blijven hangen op de fout die hij net vond.

Apolloccrypt and others added 2 commits September 3, 2026 04:34
…d an admin test that boots the admin

Three findings from the review of #368.

1. #368 bounded one redis read on the TOTP verify path and wrote the rest up in
SECURITY.md as open: "every other redis-backed route in relay.js still inherits
it and will still hang in an outage". It does not any more, and it turned out to
be three problems rather than one.

node-redis holds commands on an offline queue while it reconnects and never
retries out of it, so against a store that is gone a command neither resolves
nor rejects. disableOfflineQueue refuses it instead. That covers a socket that
died; it cannot see a connection that stays open and goes silent, where isReady
stays true and the reply simply never comes, and that is the outage a dropped
firewall rule or a wedged proxy actually produces. Only a per-command deadline
catches that one. And a deadline makes the caller safe, not the client: after a
command is lost that way node-redis goes on waiting for its reply and holds
every later command behind it, so the connection never recovers even once the
network does. Measured on 5.12.1 and 6.2.1, it reports itself ready and answers
nothing until the process restarts. The guard rebuilds the connection after two
unanswered commands in a row, not one, because a single slow command is a large
SCAN and not a wedge.

The bound sits on the client (lib/redis-deadline.js) rather than on the call
sites, because there are about 300 of them across relay.js, relay/lib,
envelope.js, admin/server.js and admin/lib, including the sMembers and sRem pair
behind /v2/user/consume-backup that #368 bounded on the route next to it and not
on this one. A per-call-site list is wrong the first time somebody adds a route.
An exceeded deadline, a closed socket and an offline client all arrive as one
RedisUnavailableError; redisOutage503 answers it on the 31 route-level catches
that used to report an outage as 400 bad_request or 500 internal, a new
top-level catch on the request handler answers it for everything else, and the
admin's error middleware does the same. That top-level catch is a fix in its own
right: http.createServer was handed a 4000 line async callback with nothing
behind it, so any throw was an unhandled rejection, which is no answer to the
client and a process exit on Node 22.

PARAMANT_REDIS_DEADLINE_MS keeps the name #368 gave it and is now the single
configuration source for both services. What is given up: during an outage every
request pays the deadline once before it is refused, and a deployment whose
redis routinely takes longer than a second must raise the knob rather than
remove it.

The health routes were part of the same blind spot. /v2/health/deep listed eight
checks and never mentioned the store that holds every TOTP secret, so it stayed
green through an outage. It now carries a redis check. 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, saying degraded when redis cannot be reached. The
relay's own /health touches no redis and still answers 200, which is correct.

2. POST /api/user/login did more work for an address that exists than for one
that does not: a second relay call and one more redis read. The status codes
were already identical, deliberately, but the clock was not. Measured on a
booted admin over 200 requests per case, interleaved, one source address each,
with the relay stub given a realistic 3 ms cost for the verify call, the median
was 6.44 ms against 2.20 ms and the two ranges did not overlap at all, so one
request classified an address with no credentials at all. Every credential
answer is now held to t0 plus PARAMANT_LOGIN_MIN_ANSWER_MS, default 250 ms, and
the not-found branch makes the same number of redis calls as the found one. The
same measurement afterwards reads 251.37 against 251.27 ms with the ranges fully
overlapping. The 429 and the 428 are not padded: their status codes tell them
apart whatever the clock says, and holding the 428 back would only delay the
page that is waiting to start hashing.

3. admin/test/login-ratelimit.test.js drove the limiter module through an
attemptLogin() helper written inside the test file and then read server.js as a
string to check the order of three calls. Both halves are worth keeping and
neither ever ran the handler, so the suite could pass while the route was wrong.
admin/test/_admin-server.js is the counterpart of relay/test/_relay-server.js:
it spawns the real admin/server.js against a stub relay and speaks HTTP to it.
login-http.test.js runs the reviewer's scenario on that, ten wrong codes on one
address from three source addresses followed by the owner solving a real 2^18
proof-of-work, plus the per-IP refusal, the IP refund on a priced attempt and
the relay 503 being passed through. Checked against the code it is meant to
catch: three of its five tests fail against the pre-#368 admin, and so does
login-timing.test.js.

The admin CI job now gets a redis service and npm ci, because until now it
installed nothing, which is the reason every admin suite was a lib test or a
source-text assertion in the first place.
…, and a delay that only existing accounts paid

1. The redis deadline made a latent bug in every rate limiter reachable in a
single request. All of them were INCR followed by a conditional expiry, `if
(count === 1) await expire(key, WINDOW)`, which is correct only while those two
commands always happen together. If the INCR outlives the deadline while the
server still executes it, the caller gets an outage and the expiry is never
sent; the next INCR returns 2, so no later call sets it either, and TTL -1 is
for ever. Measured on a booted admin with the replies from redis dropped for the
duration of one login: the per-IP counter stood at 9 with TTL -1 and that source
address kept getting 429 until the key was deleted by hand. The same shape
stranded the login failure counter, which is a proof-of-work bill that never
lifts on an address anybody may name, and the monthly counters in
relay/lib/quota.js, which is an account permanently over its limit.

All 23 INCR call sites in both services now go through lib/redis-counter.js,
which sets the expiry unconditionally after every INCR, with NX so it can only
create a window and never slide one. The healthy case is unchanged; a lost
window is repaired by the first request that lands after it instead of never.
EXPIRE NX needs Redis 7.0 and docker-compose pins 7.4.8 by digest, but 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 worse than
the bug. ratelimit-ttl.test.js reproduces it against a real admin behind a proxy
that delivers commands and drops replies, which is the only sabotage that
strands a counter: a full outage never executes the INCR either. Against the
previous revision both counters come back TTL -1.

2. The fixed floor did not close the existence oracle, and the part it missed
was three orders of magnitude wider than the part it caught. relay.js sleeps
userMfaDelayMs before it checks a code, 250 ms per failure past ten up to two
seconds, and only a request naming an account that exists ever reaches that
sleep. Twelve wrong codes from rotating source addresses put an address there,
and nothing refuses them, because the per-address counter deliberately prices
rather than denies. Measured over 100 requests per case with the stub relay
charging the same throttle: at twelve prior failures 509.91 ms against 251.61,
at twenty 2010.23 against 251.82, ranges not overlapping either time. The 503
totp_unavailable answer was unfloored as well and is only reachable for an
address that has an account.

The delay now belongs to the admin, which owns a counter keyed on the hashed
address and increments it for a miss exactly as for a hit, so it cannot know
whether the account exists. 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 including the 503, and the admin tells the relay it
has already paid. 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. Same instrument after:
251.86 against 251.87 at zero failures, 751.84 against 751.76 at twelve, 2251.90
against 2252.11 at twenty, all overlapping. login-timing.test.js now runs at all
three levels with the shipped default floor and the real throttle values, pays a
real proof-of-work for the levels above the threshold, and pins the flag itself;
it fails against the previous revision on the twelve-failure level.

The .env.example entry says what the default covers and what is added on top,
and an answer that overruns its floor is logged, because at that point the floor
has stopped being one. /health no longer repeats the deadline error, which
carried the configured bound in its text on an unauthenticated route.

SECURITY.md carries both findings and two follow-ups that are recorded rather
than fixed here: consumeBackupCode can spend a code while the caller sees 503,
because the read, the argon2 verification and the removal are three bounded
calls and not one atomic one, and regenerateBackupCodes is a DEL followed by a
SADD with nothing between them.
@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Herstelronde 3: de backup-code-route, en de twee kleinere punten

Rebased op origin/main (39179f50), derde commit op dezelfde branch: 4191f185.

Blokker - /api/user/login-with-backup

Bevestigd, en de oorzaak zit dieper dan de ontbrekende mirror. Op die route is het werk geen 4 ms redis-leeswerk maar argon2: consumeBackupCode verifieert de code tegen elke opgeslagen hash tot er één matcht, en een foute code matcht er geen, dus een misser kost tien volle argon2id-verificaties op 64 MiB met timeCost 3.

Zelf gemeten op deze machine (12th Gen i7-1260P), tien echt opgeslagen codes:

p50 min max
één verificatie 49.7 ms 44.7 ms 95.9 ms
tien (één foute code) 494.2 ms 462.3 ms 870.9 ms

Een vloer van 250 ms zit daar dus ver onder. Jullie meting klopt, en de admin schreeuwde het al: answer overran its floor, 40 van de 40.

Twee wijzigingen.

  1. Een eigen vloer: PARAMANT_LOGIN_BACKUP_MIN_ANSWER_MS, default 1500 ms. Dat is 1,7x de traagste tien-hash-misser die ik hier meet en 3x de mediaan. Een tragere machine heeft meer nodig en zegt dat ook, want elke overschrijding wordt gelogd.
  2. De mirror-throttle op de bestaande bk:email:<hash>-teller, bovenop de vloer. Die teller wordt opgehoogd vóór findUserByEmail, dus hij is per definitie hetzelfde getal voor een treffer en een misser.

Waarom die route een eigen curve krijgt en niet mirrorThrottleMs. De route weigert bij vijf pogingen per adres per venster, dus de drempel van tien uit auth-throttle.js is er onbereikbaar: een mirror op die drempel zou nul zijn voor élke poging die de route toestaat, en dat is geen mirror. backupThrottleMs start daarom na de eerste poging, met dezelfde stap (250 ms) en hetzelfde plafond (2000 ms). webauthn.rateHit heeft er een rateHitCounted naast gekregen die de telling teruggeeft; rateHit delegeert.

Meetbewijs. login-timing.test.js boot voor deze route een echte relay in plaats van de stub, want het orakel ís de argon2-kost en een stub zou de stub meten. Hij enrolt een account, controleert dat er echt tien hashes staan (sCard), en meet dan een foute code tegen bestaand en afwezig, op 0 en 4 eerdere pogingen. Vijf eerdere pogingen is een 429 voor béíde gevallen, dus geen credential-antwoord; daarom 4 en niet 5.

Tegen de vorige revisie (57866599):

not ok 3 - the backup-code route answers in the same time whether the address has an account or not
  error: 'exists must be held to its floor of 1500ms; at 0 prior attempts:
          exists p50=491.99 [487.85, 625.41], absent p50=253.16 [251.39, 253.49]'

Tegen deze revisie: 4/4 groen, 19 checks. De test faalt ook als de admin ook maar één keer answer overran its floor logde, dus de vloer wordt niet stilzwijgend te laag.

login-http.test.js dekt de route nu ook: verdicten, de per-adres-limiet (zesde poging 429), de sessiecookie, en de assertie dat élke consume-backup-aanroep throttled_upstream meestuurt.

Kleinere punten

email als object gaf 500 in 1 ms. {"email": {}} is truthy, dus if (!email) liet het door naar String(email).trim().toLowerCase(), wat gooide. Beide loginroutes controleren nu het type (ook totp en backup_code) en antwoorden 400 missing_fields, hetzelfde antwoord voor iedereen. Sabotage tegen de vorige revisie:

not ok 7 - a body that is not what it says it is gets a 400, not a 500
  error: '/user/login with email={} must be a 400, got 500 {"error":"internal_error"}'

"23" gecorrigeerd naar 18 in SECURITY.md en de changelog, met de uitsplitsing: admin/lib/login-ratelimit.js (2), admin/lib/webauthn.js (1), admin/server.js (8), relay/lib/quota.js (6), relay/relay.js (1). De 23 telde de twee kopieën van de helper mee en telde admin/server.js verkeerd. De PR-body hierboven is bijgewerkt.

Het 503-restorakel, vastgelegd

Staat nu in SECURITY.md als eigen kopje. Kort: als alléén de relay-store stuk is, antwoordt een adres mét account 503 totp_unavailable (de fail-closed replay-guard, doorgegeven) en een adres zónder account 401, omdat dat tweede de relay nooit bereikt. De vloer maakt ze even snel; de statuscodes verschillen. In de verscheepte topologie is dat niet bereikbaar, want docker-compose.yml geeft beide diensten dezelfde redis en dan kan de admin sowieso geen login afhandelen. Het wordt bereikbaar zodra iemand ze splitst, en dat hoort geen verrassing te zijn voor wie dat doet.

Suites

suite ronde 2 nu
tests/static-sanity.sh PASS, 11 checks, 127 suites PASS, 11 checks, 130 suites
npx eslint@9 . schoon schoon
relay unit 199 199 pass, 0 fail
relay route (echte redis) 91 91 pass, 0 fail
admin (echte redis) 63 67 pass, 0 fail (~1m50s)
root integration 176 190 pass (2 skipped), 0 fail
relay boot 11 11 pass, 0 fail

De admin-CI-job draait dit met een redis-service en npm ci; login-timing heeft daarnaast relay/node_modules nodig voor de echte argon2 en zegt dat bij naam als het ontbreekt (ADMIN_TEST_SKIP=relay).

…loor of 250 ms was nowhere near it

The blocker from the third review. /api/user/login-with-backup got the floor
from the previous round and nothing else, and on that route the work is not four
milliseconds 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. Measured on
this machine with ten codes really stored: one verification p50 49.7 ms, ten of
them p50 494.2 ms with a max of 870.9 ms. An address with no account pays none
of it. Through the route that read as 472.7 ms against 251.6 ms with the ranges
not overlapping, and the admin logged "answer overran its floor" on forty
requests out of forty, which is the code saying out loud that the number it had
been given was not a floor.

Two changes. The route has a floor of its own,
PARAMANT_LOGIN_BACKUP_MIN_ANSWER_MS, 1500 ms by default, which is 1.7x the
slowest ten-hash miss measured here and 3x the median; a slower machine needs
more and says so on every overrun. And the per-address throttle is mirrored onto
this route as well, on the counter it already keeps for the address, which is
incremented before the account lookup and is therefore 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 built on that threshold would be zero for
every attempt the route allows. It starts after the first attempt instead, with
the same step and the same ceiling.

login-timing.test.js measures this case against a REAL relay rather than the
stub, because the oracle here is the argon2 cost and a stub would be measuring
the stub. It enrols an account, checks that ten hashes are really stored, and
compares 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 cases, which
is not a credential answer. It also fails if the admin logged a single floor
overrun. Against the previous revision it reads 491.99 ms against 253.16 ms with
no overlap. login-http.test.js now covers the route's wiring as well: the
verdicts, the per-address ceiling, the session cookie, and the flag that keeps
the relay from charging its account-keyed delay a second time.

Two smaller things from the same review. A request body whose email is not a
string answered 500 in about a millisecond, because {"email": {}} is truthy and
`if (!email)` waved it through into String(email).trim().toLowerCase(), which
threw: an unhandled throw on an unauthenticated route, and the fastest answer
either login handler had. Both routes check the type now and answer 400. And the
count of INCR call sites in SECURITY.md and the changelog said 23, which counted
the two copies of the helper itself and miscounted admin/server.js; it is 18.

SECURITY.md also records a residual that belongs to the deployment rather than
to the code: if the relay's redis is unreachable while the admin's is not, an
address with an account answers 503 and one without answers 401, because the
second never reaches the relay. docker-compose gives both services the same
store, so it is not reachable as shipped, but splitting them turns an outage
into an enumeration oracle and that should not be a surprise to whoever splits
them.

The real relay that case boots will not start without @paramant/core, which is a
file-link to the sibling repo and is built in exactly one CI job. So the admin
job declares it as a skip by name and the relay-crypto job, which has the
binding, runs that suite for real and fails if it skips there. Without the
declaration the case is a hard failure, so it cannot quietly stop running in
both jobs at once.
@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Meetbewijs /api/user/login-with-backup, vóór en ná

Op 2eb5ce67, alle checks groen. Echte relay, echte argon2, tien opgeslagen back-upcodes (sCard bevestigt 10), foute code, 8 samples per geval, één X-Real-IP per request.

Vóór (ADMIN_SERVER_JS op 57866599, de vorige revisie):

eerdere pogingen geval p50 min max status
0 bestaand 507.33 ms 476.02 536.93 401
0 afwezig 252.03 ms 251.24 253.96 401
0 delta p50 255.30 ms, ranges overlappen niet
4 bestaand 483.21 ms 474.10 489.40 401
4 afwezig 252.30 ms 250.75 252.64 401
4 delta p50 230.91 ms, ranges overlappen niet
5 beide 2.19 / 2.41 ms 429

answer overran its floor gelogd: 18 keer (van de 32 credential-antwoorden).

, zelfde instrument:

eerdere pogingen geval p50 min max status
0 bestaand 1502.38 ms 1501.23 1503.47 401
0 afwezig 1503.31 ms 1500.56 1503.67 401
0 delta p50 -0.92 ms, ranges OVERLAPPEN
4 bestaand 2252.16 ms 2251.29 2253.03 401
4 afwezig 2253.15 ms 2250.65 2253.62 401
4 delta p50 -0.99 ms, ranges OVERLAPPEN
5 bestaand 4.01 ms 2.30 4.51 429
5 afwezig 3.42 ms 2.28 4.73 429
5 delta p50 0.59 ms, ranges overlappen

answer overran its floor gelogd: 0.

Waarom 4 en niet 5 als "eerdere mislukkingen" in de assertie: bij vijf eerdere pogingen is de zesde een 429 voor béíde gevallen, dus geen credential-antwoord. De tabel laat dat niveau zien zodat het zichtbaar is dat ook dáár niets uiteenloopt. 1502 en 2252 zijn de vloer (1500) plus de mirror-throttle (0 respectievelijk 750 ms bij vier eerdere pogingen).

CI

Alle checks groen op 2eb5ce67. De backup-case draait écht in relay - crypto suite, de enige job met @paramant/core gebouwd:

12 success Install admin dependencies
13 success Admin login timing against a real relay
   ok 3 - the backup-code route answers in the same time whether the address has an account or not
   # login-timing: 19 checks passed   # pass 4   # fail 0

De admin - unit suite job declareert die ene case als ADMIN_TEST_SKIP=relay (relay.js start niet zonder de binding) en de crypto-job faalt expliciet als hij dáár zou skippen, zodat hij niet in beide jobs tegelijk stil kan vallen.

@Apolloccrypt
Apolloccrypt merged commit 1cb2de7 into main Sep 3, 2026
14 checks passed
Apolloccrypt added a commit that referenced this pull request Sep 3, 2026
…e bar is measured on three pages

Review of #392 found the hole in the first round. developer.html keeps its own
navigation (KEEP_OWN_NAV in frontend/apply-nav.py), is stamped by hand, and
carries a hamburger. The round moved Sign in and Help out of the bar below
700px and into a strip under the drawer, and that page never got a strip. So on
a 390px screen both links measured 0x0 there, where main still had them in the
bar. A page the generator does not own is a page a generated fix does not
reach.

Three things, because one of them alone would leave the same gap open.

The markup. developer.html carries the strip now, like every stamped page.

The script. nav.js builds the strip when a page has a menu button and no strip.
A page can forget the markup; this cannot forget to build it. That is a floor
under the live site, not a substitute for the markup, which is why it comes
with the check below rather than instead of it.

The check. tests/navigation-shell reads every .html in frontend/, not only the
51 the generator writes, and fails on any page that has id="nav-hamburger"
without id="nav-mobile-tail". 52 pages carry the button. Taking the strip back
out of developer.html turns it red and names the file.

And the measurement moved. The three phone-bar checks ran on the homepage
alone, which is exactly why they were green while /developer was broken. They
run on /, /pricing and /developer now, each with a fourth check that the strip
really holds /auth/login and /help at 44px or more. /developer measures logo
x16-95, Create account x181-318, menu x330-374, gaps 86 and 12, all heights 44.

39 checks, all green. Rebased on main with #379, #390 and #391.
@Apolloccrypt
Apolloccrypt deleted the fix/auth-hardening-2 branch September 5, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants