Skip to content

feat: SIWE-style nonce verification has a TOCTOU race - #130

Open
Adeyemi-cmd wants to merge 2 commits into
StepFi-app:mainfrom
Adeyemi-cmd:SIWE_style_nonce
Open

feat: SIWE-style nonce verification has a TOCTOU race#130
Adeyemi-cmd wants to merge 2 commits into
StepFi-app:mainfrom
Adeyemi-cmd:SIWE_style_nonce

Conversation

@Adeyemi-cmd

@Adeyemi-cmd Adeyemi-cmd commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 NULL before verify, burn-on-failure) + per-wallet throttling on POST /auth/verify; merge upstream/main domain-binding envelope so message_hash binding survives


📝 Description

What was the problem?

AuthService.verifySignature() (src/modules/auth/auth.service.ts:94–142 old) used a read-then-write pattern:**

  1. SELECT ... WHERE used_at IS NULL AND nonce = ? AND wallet = ? (src/modules/auth/auth.service.ts:96–102 old) — returns the same unused row to concurrent callers.
  2. Expensive signature verification (src/modules/auth/auth.service.ts:109–136 old) — Keypair.verify over nonce and Stellar Signing Key: <nonce> fallback — sits between check and mark.
  3. Only afterwards UPDATE nonces SET used_at = ... (src/modules/auth/auth.service.ts:141 old).

Two concurrent POST /auth/verify carrying 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 on verify, 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 5dd4772 atomic claim (now preserved on top of upstream envelope):

  • Before: SELECT ... .is('used_at', null).single()if (expires_at < now)StrKey/Keypair.verifyUPDATE 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 claim UPDATE nonces SET used_at=claimedAt WHERE id=? AND used_at IS NULL with {count:'exact'} (src/modules/auth/auth.service.ts:207) → DATABASE_NONCE_CLAIM_FAILED on claimError, AUTH_NONCE_NOT_FOUND on claimedCount===0 (covers count and data.length fallback) → expiry after claim if (new Date(expires_at) < now) AUTH_NONCE_EXPIRED stays burned → StrKeytry { Keypair.fromPublicKey } branching on signatureType (see upstream envelope below). No trailing UPDATE — 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, bad StrKey, expired envelope.expirationTime), the nonce stays burned; caller must POST /auth/nonce again. 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:1HEAD 5dd4772 TOCTOU 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 call NOT_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) via Promise.allSettled + count race, replay after success fails, replay during failure burns, expired nonce stays burned.
    • Plus upstream envelope suites (retained): generateNonce envelope fields/message_hash stored, envelope/sep0043 verify, AUTH_CHALLENGE_MISMATCH/DOMAIN_MISMATCH/NETWORK_MISMATCH, expirationTime expiry, missing message_hash, legacy flag/sunset.
  • test/unit/modules/auth/auth-throttler.guard.spec.ts:1 (new) — getTracker returns wallet:<address> for body.wallet and user.wallet, falls back to IP.

  • test/unit/modules/auth/auth.controller.spec.ts — updated to provide AuthWalletThrottlerGuard mock.

  • test/e2e/modules/auth/auth.e2e-spec.ts (from upstream) — asserts POST /auth/nonce returns message and full envelope/sep0043 flows.



@Adeyemi-cmd
Adeyemi-cmd requested a review from EmeditWeb as a code owner August 28, 2026 00:32

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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 🤖

@EmeditWeb

Copy link
Copy Markdown
Member

⚠️ @Adeyemi-cmd this PR now has merge conflicts with the base branch (likely because another PR was merged first).

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.
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.

critical: SIWE-style nonce verification has a TOCTOU race — one nonce can authenticate multiple sessions

2 participants