feat: SIWE-style nonce verification has a TOCTOU race - #130
Conversation
EmeditWeb
left a comment
There was a problem hiding this comment.
⚠️ Automated Audit: partial
@Adeyemi-cmd Good start — please look into the gaps identified below.
The PR correctly implements atomic nonce consumption by replacing the SELECT→verify→UPDATE sequence with a conditional UPDATE ... WHERE used_at IS NULL executed before signature verification, which directly eliminates the TOCTOU window described in issue #116. The burn-on-failure tradeoff is properly documented and implemented, per-wallet throttling is added via AuthWalletThrottlerGuard, and comprehensive tests cover parallel double-verify, replay after success/failure, and expired nonce scenarios. CI passes (build-test green). The PR description is terse (only ~3% keyword overlap with the issue) and leaves the template checkboxes unfilled, but the code changes substantively address every acceptance criterion in the issue.
⚖️ Adjusted by bot policy: gaps were still identified; the PR title/summary is terse — a descriptive title and a proper description of what/why/testing are required.
Gaps identified:
- PR description is a nearly-empty template — should explain what changed, why (linking #116), and how it was tested
CI checks: ✅ PASSED: build-test
Merge conflicts: ✅ none — but the PR is blocked (failing/missing required checks or reviews).
Audited by stepfi-audit-bot 🤖
|
Please rebase/merge the base branch into your branch and resolve the conflicts — a fresh audit will run automatically once new commits land. |
EmeditWeb
left a comment
There was a problem hiding this comment.
⚠️ Automated Audit: partial
@Adeyemi-cmd Good start — please look into the gaps identified below.
The diff substantively fixes the TOCTOU root cause described in #116: the read-then-write pattern is replaced by an atomic conditional claim (UPDATE nonces SET used_at = ? WHERE id = ? AND used_at IS NULL with count check) executed BEFORE signature verification, with burned-on-failure semantics and the trailing post-verify UPDATE removed — so one (wallet, nonce) pair can produce at most one successful session. Per-wallet throttling is genuinely wired in via a new wallet-keyed AuthWalletThrottlerGuard applied to POST /auth/verify, and regression tests cover the atomic race, replay-after-success, replay-after-failure, and expired-nonce-burned paths. CI (build-test) passed on the actual changes and the PR's claimed test counts (38 suites, 425 tests) are consistent with the independent run, not contradicted by it.
⚖️ Adjusted by bot policy: confidence 85% is below the 90% threshold for a full approval; gaps were still identified.
Gaps identified:
- Atomicity is proven only via mocked unit tests simulating the count race; no real Postgres-level parallel e2e test exercises the conditional UPDATE under genuine concurrency (acceptable since Postgres UPDATE is natively atomic, but the issue explicitly asked for a concurrency proof).
- No e2e test asserts a 429 on POST /auth/verify past the per-wallet throttle limit - test coverage for the throttling wiring is unit-level only.
- Issue's secondary mention of expired-nonce cleanup that is 'tied to this flow' is not addressed (the expired row is now burned but no deletion/cleanup job was added).
CI checks: ✅ PASSED: build-test
Merge conflicts: ✅ none — but the PR is blocked (failing/missing required checks or reviews).
Audited by stepfi-audit-bot 🤖
EmeditWeb
left a comment
There was a problem hiding this comment.
⚠️ Automated Audit: partial
@Adeyemi-cmd Good start — please look into the gaps identified below.
The diff genuinely replaces the SELECT→verify→UPDATE sequence with an atomic conditional claim (UPDATE ... WHERE id = ? AND used_at IS NULL with count === 0 rejected as AUTH_NONCE_NOT_FOUND) executed before signature verification and removes the trailing UPDATE, which eliminates the TOCTOU window; expired rows are burned after claim and the burn-on-failure tradeoff is documented in code. Per-wallet throttling is genuinely wired via the new AuthWalletThrottlerGuard (body/user wallet key with IP fallback) plus the existing IP throttle. Regression tests cover the claim race, burn-on-failure, replay-after-success, expired-nonce, and parallel double-verify via Promise.allSettled, and CI passed. Minor residual: atomicity is proven only at the unit-mock level (simulated count races), not a real-Postgres integration test, and the issue's secondary note about expired-nonce cleanup was not addressed beyond burning on claim.
⚖️ Adjusted by bot policy: confidence 85% is below the 90% threshold for a full approval; gaps were still identified.
Gaps identified:
- Concurrency/atomicity proven only via mocked unit tests (simulated count===0 races); no e2e/integration test against real Postgres proving parallel identical verify requests yield exactly one success
- No cleanup mechanism for expired nonces (secondary note in the issue) — rows are burned on claim but a scheduled cleanup job was not added
CI checks: ✅ PASSED: build-test
Merge conflicts: ✅ none — but the PR is blocked (failing/missing required checks or reviews).
Audited by stepfi-audit-bot 🤖
|
@EmeditWeb please review |
fix merge conflicts |
|
Please rebase/merge the base branch into your branch and resolve the conflicts — a fresh audit will run automatically once new commits land. |
EmeditWeb
left a comment
There was a problem hiding this comment.
⚠️ Automated Audit: partial
@Adeyemi-cmd Good start — please look into the gaps identified below.
The code changes genuinely fix the TOCTOU nonce race by implementing atomic claim (UPDATE ... WHERE used_at IS NULL) before signature verification, and add per-wallet throttling via AuthWalletThrottlerGuard. Tests cover atomicity, expiry, burn-on-failure, and throttling. However, the PR has merge conflicts with the base branch that prevent it from being merged/approved.
CI checks: ✅ PASSED: build-test
Merge conflicts:
Audited by stepfi-audit-bot 🤖
7daaa21 to
1783f17
Compare
PR: Fix SIWE nonce TOCTOU race — atomic claim + per-wallet throttling + merge domain-binding
🔖 Title
critical: SIWE-style nonce verification has a TOCTOU race— make nonce consumption atomic (UPDATE ... WHERE used_at IS NULLbefore verify, burn-on-failure) + per-wallet throttling onPOST /auth/verify; mergeupstream/maindomain-binding envelope somessage_hashbinding survives📝 Description
What was the problem?
AuthService.verifySignature()(src/modules/auth/auth.service.ts:94–142old) used a read-then-write pattern:**SELECT ... WHERE used_at IS NULL AND nonce = ? AND wallet = ?(src/modules/auth/auth.service.ts:96–102old) — returns the same unused row to concurrent callers.src/modules/auth/auth.service.ts:109–136old) —Keypair.verifyovernonceandStellar Signing Key: <nonce>fallback — sits between check and mark.UPDATE nonces SET used_at = ...(src/modules/auth/auth.service.ts:141old).Two concurrent
POST /auth/verifycarrying the same(wallet, nonce, signature)both execute step 1 before either reaches step 3, both pass verification, both mint independent sessions (each with its own refresh token). One stolen/intercepted nonce+signature pair therefore creates unlimited sessions — textbook TOCTOU, window widened by the verify cost. Additionally there was no per-wallet rate limit onverify, enabling offline-style brute force of the SEP-0043 fallback space at network speed, and no guarantee that an expired nonce stays burned.🔄 Changes Made
Core —
src/modules/auth/auth.service.ts(src/modules/auth/auth.service.ts:94)HEAD
5dd4772atomic claim (now preserved on top of upstream envelope):Before:
SELECT ... .is('used_at', null).single()→if (expires_at < now)→StrKey/Keypair.verify→UPDATE used_at(post-verify, not atomic).After (
src/modules/auth/auth.service.ts:181–270):SELECT id, expires_at, issued_at, message_hash ... .is('used_at', null).single()→if (nonceError || !nonceRecord)→ atomic claimUPDATE nonces SET used_at=claimedAt WHERE id=? AND used_at IS NULLwith{count:'exact'}(src/modules/auth/auth.service.ts:207) →DATABASE_NONCE_CLAIM_FAILEDonclaimError,AUTH_NONCE_NOT_FOUNDonclaimedCount===0(coverscountanddata.lengthfallback) → expiry after claimif (new Date(expires_at) < now)AUTH_NONCE_EXPIREDstays burned →StrKey→try { Keypair.fromPublicKey }branching onsignatureType(see upstream envelope below). No trailingUPDATE— claim already burned the row.Security tradeoff documented in code comments (
src/modules/auth/auth.service.ts:194–206): if verification subsequently fails (bad signature, badStrKey, expiredenvelope.expirationTime), the nonce stays burned; caller mustPOST /auth/nonceagain. This converts replay into DoS-on-self (one wasted challenge) vs unlimited sessions — correct vs allowing unlimited creations.Tests
test/unit/modules/auth/auth.service.spec.ts:1— HEAD5dd4772TOCTOU suites (now merged, 325+ lines added):should throw AUTH_NONCE_NOT_FOUND when atomic claim loses race (count===0)(test/unit/modules/auth/auth.service.spec.ts:388)should throw AUTH_NONCE_EXPIRED when nonce is past expiry (nonce stays burned)(test/unit/modules/auth/auth.service.spec.ts:395)should mark nonce as used via atomic claim before signature verification(test/unit/modules/auth/auth.service.spec.ts:610)should burn the nonce even when signature verification fails (DoS-on-self tradeoff)— second callNOT_FOUND(test/unit/modules/auth/auth.service.spec.ts:617)verifySignature — atomicity / concurrency(test/unit/modules/auth/auth.service.spec.ts:677):parallel double-verify yields exactly one success (atomic claim)viaPromise.allSettled+countrace,replay after success fails,replay during failure burns,expired nonce stays burned.generateNonceenvelope fields/message_hashstored,envelope/sep0043verify,AUTH_CHALLENGE_MISMATCH/DOMAIN_MISMATCH/NETWORK_MISMATCH,expirationTimeexpiry, missingmessage_hash, legacy flag/sunset.test/unit/modules/auth/auth-throttler.guard.spec.ts:1(new) —getTrackerreturnswallet:<address>forbody.walletanduser.wallet, falls back to IP.test/unit/modules/auth/auth.controller.spec.ts— updated to provideAuthWalletThrottlerGuardmock.test/e2e/modules/auth/auth.e2e-spec.ts(from upstream) — assertsPOST /auth/noncereturnsmessageand fullenvelope/sep0043flows.