From c433a14ba95505e9e738789eab987b6c76202d56 Mon Sep 17 00:00:00 2001 From: shiv Date: Sat, 8 Aug 2026 11:18:33 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20TOCTOU=20race=20condition=20in=20signature=20verifier=20non?= =?UTF-8?q?ce=20checking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: shiv --- .jules/sentinel.md | 4 ++++ pkg/signing/verify.go | 25 +++++++++-------------- pkg/signing/verify_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..e9c373c --- /dev/null +++ b/.jules/sentinel.md @@ -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. diff --git a/pkg/signing/verify.go b/pkg/signing/verify.go index b7ceca9..dfc3fd2 100644 --- a/pkg/signing/verify.go +++ b/pkg/signing/verify.go @@ -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 { @@ -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 } @@ -248,24 +246,18 @@ 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 { 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) { @@ -273,6 +265,7 @@ func (v *Verifier) recordNonce(nonce string) { } } } + return false } func absDuration(d time.Duration) time.Duration { diff --git a/pkg/signing/verify_test.go b/pkg/signing/verify_test.go index 468d882..46eec59 100644 --- a/pkg/signing/verify_test.go +++ b/pkg/signing/verify_test.go @@ -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) + } +}