Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2026-08-08 - Atomic Nonce Verification for Replay Prevention
**Vulnerability:** TOCTOU race condition in `pkg/signing/verify.go` allowed concurrent duplicate requests with identical nonces to bypass replay protection because `isReplayedNonce` checked nonces before `recordNonce` was called at the end of message verification.
**Learning:** Checking nonces separately from recording them leaves a race condition window under concurrent load, and checking nonces before signature verification allows unauthenticated requests to pollute or query nonce tracking.
**Prevention:** Perform cryptographic signature verification first, followed by an atomic check-and-record operation for nonces under mutex lock.
25 changes: 9 additions & 16 deletions pkg/signing/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,6 @@ func (v *Verifier) Verify(msg []byte) error {
return fmt.Errorf("signature verification failed: signed_at %s is outside allowed window (±%s)", env.SignedAt, maxTimestampSkew)
}

// Check replay
if v.isReplayedNonce(env.Nonce) {
return fmt.Errorf("signature verification failed: nonce %s already seen (replay attack)", env.Nonce)
}

// Decode signature
sigBytes, err := base64.StdEncoding.DecodeString(env.Signature)
if err != nil {
Expand All @@ -196,8 +191,11 @@ func (v *Verifier) Verify(msg []byte) error {
return fmt.Errorf("signature verification failed: %w", err)
}

// Record nonce after successful verification
v.recordNonce(env.Nonce)
// Atomically check and record nonce after successful signature verification (anti-replay)
if v.checkAndRecordNonce(env.Nonce) {
return fmt.Errorf("signature verification failed: nonce %s already seen (replay attack)", env.Nonce)
}

return nil
}

Expand Down Expand Up @@ -248,31 +246,26 @@ func canonicalJSON(raw json.RawMessage) ([]byte, error) {
return json.Marshal(v)
}

func (v *Verifier) isReplayedNonce(nonce string) bool {
func (v *Verifier) checkAndRecordNonce(nonce string) bool {
Comment thread
blue4209211 marked this conversation as resolved.
v.nonceMu.Lock()
defer v.nonceMu.Unlock()

if _, seen := v.seenNonces[nonce]; seen {
return true
}
return false
}

func (v *Verifier) recordNonce(nonce string) {
v.nonceMu.Lock()
defer v.nonceMu.Unlock()

v.seenNonces[nonce] = time.Now()

// Evict old nonces if map is too large
if len(v.seenNonces) > maxNonces {
// Evict old nonces if map is too large (rate-limited to once every 100 additions)
if len(v.seenNonces) > maxNonces && len(v.seenNonces)%100 == 0 {
cutoff := time.Now().Add(-maxTimestampSkew * 2)
for n, t := range v.seenNonces {
if t.Before(cutoff) {
delete(v.seenNonces, n)
}
}
}
return false
}

func absDuration(d time.Duration) time.Duration {
Expand Down
41 changes: 41 additions & 0 deletions pkg/signing/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,44 @@ func TestSignAndVerify_RoundTrip(t *testing.T) {
t.Fatalf("Verify: %v", err)
}
}

func TestVerify_ConcurrentReplay(t *testing.T) {
pub, priv := generateTestKeypair()
v, err := NewVerifier(base64.StdEncoding.EncodeToString(pub), testLogger())
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}

signer := testSigner(t, priv)
msg := []byte(`{"action":"db_query","datasource_id":"ds-1","params":{"query":"SELECT 1"}}`)
signed, err := signer.Sign(msg)
if err != nil {
t.Fatalf("Sign: %v", err)
}

const goroutines = 20
errCh := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
go func() {
errCh <- v.Verify(signed)
}()
}

successCount := 0
replayCount := 0
for i := 0; i < goroutines; i++ {
err := <-errCh
if err == nil {
successCount++
} else {
replayCount++
}
}

if successCount != 1 {
t.Fatalf("expected exactly 1 verification success, got %d", successCount)
}
if replayCount != goroutines-1 {
t.Fatalf("expected %d replay rejections, got %d", goroutines-1, replayCount)
}
}
Loading