Skip to content

fix: prevent nonce poisoning (WAPI-1121)#69

Open
chakra-guy wants to merge 1 commit intomainfrom
cyfrin/wapi-1121
Open

fix: prevent nonce poisoning (WAPI-1121)#69
chakra-guy wants to merge 1 commit intomainfrom
cyfrin/wapi-1121

Conversation

@chakra-guy
Copy link
Collaborator

@chakra-guy chakra-guy commented Feb 25, 2026

Summary

Prevents nonce poisoning attacks by deferring nonce persistence until after successful decryption. Previously, nonces were saved immediately on message receipt, allowing an attacker to send high-nonce messages that fail decryption but permanently block legitimate messages.

  • Nonce is now confirmed via callback only after successful decryption in BaseClient
  • Added MAX_NONCE_JUMP (100) to reject suspiciously large nonce jumps from known senders
  • Added NaN recovery in nonce storage
  • Added mutex around confirmNonce to prevent concurrent race conditions

Jira

Test plan

  • Unit tests for confirmNonce, NaN recovery, nonce regression prevention
  • All existing unit tests pass (63/63)
  • Lint passes

Note

Medium Risk
Changes transport-level deduplication/nonce persistence behavior and introduces a new confirmNonce handshake between transport and client; mistakes could drop or permanently block legitimate messages (e.g., missed confirmations or nonce-jump threshold).

Overview
Hardens inbound message handling against nonce-poisoning by deferring persistence of received nonces until after successful decryption: WebSocketTransport now emits message events with a confirmNonce() callback, and BaseClient calls it only when decryptMessage succeeds.

Adds additional nonce safety rails: rejects suspicious large nonce jumps from known senders (MAX_NONCE_JUMP), tracks pending nonces to avoid double-processing before confirmation, clears pending state on clear(), and makes nonce storage more robust via NaN recovery plus a mutex-protected confirmNonce path. Tests and changelog are updated accordingly.

Written by Cursor Bugbot for commit fd3a662. This will update automatically on new commits. Configure here.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

adonesky1
adonesky1 previously approved these changes Feb 27, 2026
Copy link

@adonesky1 adonesky1 left a comment

Choose a reason for hiding this comment

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

Few minor comments otherwise LGTM

chakra-guy added a commit that referenced this pull request Feb 27, 2026
Address review feedback on #69:

- Wrap storage.confirmNonce() in try/catch inside the transport's
  callback lambda so a transient KV store failure emits an error
  instead of silently dropping a successfully decrypted message.
- Move pendingNonces cleanup outside the try block so it always
  runs regardless of storage success/failure.
- Expand MAX_NONCE_JUMP doc comment to explain why skipping the
  check for new senders is safe (spoofed messages fail decryption
  and never get confirmed).
@chakra-guy chakra-guy requested a review from adonesky1 February 27, 2026 10:18
chakra-guy added a commit that referenced this pull request Feb 27, 2026
Address review feedback on #69:

- Wrap storage.confirmNonce() in try/catch inside the transport's
  callback lambda so a transient KV store failure emits an error
  instead of silently dropping a successfully decrypted message.
- Move pendingNonces cleanup outside the try block so it always
  runs regardless of storage success/failure.
- Expand MAX_NONCE_JUMP doc comment to explain why skipping the
  check for new senders is safe (spoofed messages fail decryption
  and never get confirmed).
…ryption (WAPI-1121)

Nonces are no longer persisted immediately on message receipt. Instead,
a confirmNonce callback is emitted with each message and called by
BaseClient only after successful decryption. This prevents attackers
from poisoning the nonce tracker with high-nonce messages that fail
decryption, which would permanently block legitimate messages.

- Add MAX_NONCE_JUMP (100) to reject suspiciously large nonce jumps
- Add in-memory pendingNonces set to prevent duplicate processing
- Add NaN recovery in nonce storage
- Add mutex around confirmNonce to prevent race conditions
- Wrap confirmNonce in try/catch so storage failures don't drop messages
await this.nonceMutex.runExclusive(async () => {
const latestNonces = await this.getLatestNonces(channel);
const current = latestNonces.get(clientId) || 0;
if (nonce > current) {
Copy link
Member

Choose a reason for hiding this comment

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

if this isn't true, hasn't something gone very wrong?

const value = await this.kvstore.get(key);
const currentNonce = value ? parseInt(value, 10) : 0;
let currentNonce = value ? parseInt(value, 10) : 0;
if (Number.isNaN(currentNonce)) currentNonce = 0;
Copy link
Member

Choose a reason for hiding this comment

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

why is this needed now?

// If message.nonce <= latestNonce, it's a duplicate and we ignore it.

// Reject suspiciously large nonce jumps (but allow first message from a new sender).
if (latestNonce > 0 && message.nonce - latestNonce > MAX_NONCE_JUMP) {
Copy link
Member

@jiexi jiexi Mar 2, 2026

Choose a reason for hiding this comment

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

I don't quite understand what the max nonce jump is supposed to guard against. Is there a scenario where we think an attacker would send single messages with artificially high nonces? If so, what's the difference between than and simply spamming small incremental nonce bumps?

Could you help me understand the reasoning for always accepting the first seen nonce from the client?

Thank you! sorry if these are obvious

Comment on lines +282 to +283
// Without this, a message arriving via both live publication and
// _fetchHistory could pass the storage-based dedup check twice
Copy link
Member

Choose a reason for hiding this comment

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

should handleIncoming message be behind a mutex instead of the pendingNonces guarding logic?

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.

3 participants