Skip to content

feat(noter): server-issued challenge nonce for wallet auth replay protection#53

Open
Ashwin-3cS wants to merge 3 commits intoMystenLabs:devfrom
Ashwin-3cS:feat/noter-wallet-challenge-nonce-v2
Open

feat(noter): server-issued challenge nonce for wallet auth replay protection#53
Ashwin-3cS wants to merge 3 commits intoMystenLabs:devfrom
Ashwin-3cS:feat/noter-wallet-challenge-nonce-v2

Conversation

@Ashwin-3cS
Copy link
Copy Markdown
Contributor

Description

This change builds on the previous PR #52 , which introduced server-side signature verification but did not cover replay protection as it was out of scope. This PR introduces a server-issued challenge flow to prevent replay attacks. Given the additional scope, this is raised as a separate PR with the necessary changes and inclusions.


What changed

New getChallenge endpoint
The server generates a random 32-byte nonce, stores it in a new wallet_challenges table with a 5-minute TTL, and returns it to the client.

connectWallet now verifies against a server-issued nonce
The handler looks up the challenge by ID, deletes it atomically (consuming it), and verifies that the signature was created over that nonce. A replayed request fails because the challenge no longer exists.

Why a new table instead of reusing walletSessions?
The zkLogin flow uses a two-step pattern (create session stub without userId, then complete after verification). A similar approach was considered here, but walletSessions contains several NOT NULL columns (walletAddress, signedMessage, signature, signedAt, walletType) that have no meaningful values at challenge time. Making them nullable would weaken the schema contract for completed sessions. A dedicated table with only the required fields keeps the design cleaner.


Files changed

  • schema.ts — add walletChallenges table
  • route.ts — add getChallenge, update connectWallet handler
  • input.ts — replace message with challengeId in connectWalletInput
  • auth-button-group.tsx / wallet-button.tsx — fetch challenge before signing
  • use-auth.ts — update params type
  • wallet-client.ts — remove generateAuthMessage()
  • constant.ts — rename session storage key from zklogin:session:id to auth:session:id

Test plan

Tested locally :

  • Fresh wallet connect → session created (200)
  • Replay same connectWallet request with consumed challengeId → 404 "Challenge not found or already used"
  • Tampered address → 401 "Signature does not match address"

Screenshot 2026-03-27 011803

@ducnmm
Copy link
Copy Markdown
Collaborator

ducnmm commented Mar 26, 2026

Hi @Ashwin-3cS LGTM overall. Please fix before merge:

  • Use DELETE ... RETURNING instead of SELECT + DELETE in connectWallet — current code has a TOCTOU race condition
  • Add cleanup for expired challenges in getChallenge to prevent table bloat
  • Include the Drizzle migration file

… protection

Replace client-generated auth messages with a server-issued one-time
challenge nonce. The server generates a random nonce stored in a new
walletChallenges table with a 5-minute TTL. The client signs the nonce,
and the server deletes it on use — replaying a captured signature fails
because the challenge no longer exists.
- Use DELETE...RETURNING in connectWallet to eliminate TOCTOU race
- Clean up expired challenges in getChallenge to prevent table bloat
- Add Drizzle migration for walletChallenges table
@Ashwin-3cS Ashwin-3cS force-pushed the feat/noter-wallet-challenge-nonce-v2 branch from aa19364 to 23b2a05 Compare March 27, 2026 07:49
@Ashwin-3cS
Copy link
Copy Markdown
Contributor Author

Hi @Ashwin-3cS LGTM overall. Please fix before merge:

  • Use DELETE ... RETURNING instead of SELECT + DELETE in connectWallet — current code has a TOCTOU race condition
  • Add cleanup for expired challenges in getChallenge to prevent table bloat
  • Include the Drizzle migration file

Hi @ducnmm; thanks for the detailed review! Addressed all three points in the follow-up commit:

  • DELETE … RETURNING in connectWallet atomically consumes the challenge, no TOCTOU window
  • Expired challenge cleanup at the start of getChallenge to prevent table bloat
  • Drizzle migration included (0002_conscious_deathbird.sql + meta files, generated after resolving drizzle-kit/drizzle-orm version mismatch)

@Ashwin-3cS
Copy link
Copy Markdown
Contributor Author

The diff shows ~3k lines due to the fork base being out of sync at push time; actual change is ~1,270 lines, mostly the migration snapshot.

@Ashwin-3cS
Copy link
Copy Markdown
Contributor Author

Ashwin-3cS commented Apr 2, 2026

Resolved the pnpm-lock.yaml conflict by merging latest dev into the branch and regenerating the lockfile via pnpm install. The diff is clean; no manual edits to the lockfile.

@ducnmm let me know if there are any blockers or further changes needed for this PR.

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