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. |
…into SIWE_style_nonce — preserve atomic nonce claim (StepFi-app#116) Merge upstream/main cbd05ad (fix: domain-bind wallet signature challenges) while retaining atomic nonce claim (5dd4772). Resolves conflicts: - src/modules/auth/auth.service.ts: retains generateNonce message + message_hash/issued_at + buildChallengeMessage from cbd05ad and injects atomic UPDATE ... count:'exact' claim before verify (burn-on-failure) from 5dd4772; removes trailing UPDATE; helpers resolveChallengeMessage/assertChallengeBinding preserved. - test/unit/modules/auth/auth.service.spec.ts: merged suites keep origin domain-binding + HEAD TOCTOU atomicity (parallel double-verify, replay burned, expired burned) and fixes claimResult type. - DTOs/e2e/env/docs/migration synced from upstream. No merge markers, npm run build green, 61 tests pass. Closes StepFi-app#116, incorporates StepFi-app#118.
Closes #116
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.