VerifyToken now consumes a redemption token on its first successful verify: a replayed token within its 5-min TTL is rejected. Mirrors the existing single-use challenge-nonce store with a per-Server used-token store keyed on the token's random id, same GC/expiry approach (entries live only for the remaining TTL). The token is burned only on a successful verify (valid HMAC + unexpired + unused); forged/expired tokens never touch the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
107 lines
3.1 KiB
Go
107 lines
3.1 KiB
Go
package captcha
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// solve reproduces what the widget does, using the package's own prng, so the
|
|
// test proves CreateChallenge and Redeem agree end-to-end.
|
|
func solve(token string, ch Challenge) []string {
|
|
sols := make([]string, ch.C)
|
|
for i := 1; i <= ch.C; i++ {
|
|
salt := prng(token+strconv.Itoa(i), ch.S)
|
|
target := prng(token+strconv.Itoa(i)+"d", ch.D)
|
|
for n := 0; ; n++ {
|
|
sum := sha256.Sum256([]byte(salt + strconv.Itoa(n)))
|
|
if has := hex.EncodeToString(sum[:]); len(has) >= len(target) && has[:len(target)] == target {
|
|
sols[i-1] = strconv.Itoa(n)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return sols
|
|
}
|
|
|
|
func TestChallengeRedeemRoundTrip(t *testing.T) {
|
|
// Small difficulty keeps the test fast.
|
|
s := New([]byte("secret"), WithChallenge(5, 8, 2))
|
|
cr, err := s.CreateChallenge()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sols := solve(cr.Token, cr.Challenge)
|
|
res := s.Redeem(cr.Token, sols)
|
|
if !res.Success {
|
|
t.Fatal("valid solutions rejected")
|
|
}
|
|
if !s.VerifyToken(res.Token) {
|
|
t.Fatal("issued verification token failed VerifyToken")
|
|
}
|
|
}
|
|
|
|
func TestVerifyTokenSingleUse(t *testing.T) {
|
|
s := New([]byte("secret"), WithChallenge(5, 8, 2))
|
|
cr, _ := s.CreateChallenge()
|
|
res := s.Redeem(cr.Token, solve(cr.Token, cr.Challenge))
|
|
if !res.Success {
|
|
t.Fatal("valid redeem rejected")
|
|
}
|
|
// First verification succeeds.
|
|
if !s.VerifyToken(res.Token) {
|
|
t.Fatal("first VerifyToken should succeed")
|
|
}
|
|
// Replaying the same token within its TTL must fail — single-use.
|
|
if s.VerifyToken(res.Token) {
|
|
t.Error("replayed verification token accepted (should be single-use)")
|
|
}
|
|
// A third replay stays rejected.
|
|
if s.VerifyToken(res.Token) {
|
|
t.Error("second replay of verification token accepted")
|
|
}
|
|
// A forged/garbage token never consumes and never verifies.
|
|
if s.VerifyToken("not-a-real-token") {
|
|
t.Error("garbage token accepted")
|
|
}
|
|
// An independently issued token is unaffected by another token's burn.
|
|
cr2, _ := s.CreateChallenge()
|
|
res2 := s.Redeem(cr2.Token, solve(cr2.Token, cr2.Challenge))
|
|
if !res2.Success {
|
|
t.Fatal("second redeem rejected")
|
|
}
|
|
if !s.VerifyToken(res2.Token) {
|
|
t.Error("fresh independent token rejected after another token was burned")
|
|
}
|
|
}
|
|
|
|
func TestRedeemRejects(t *testing.T) {
|
|
s := New([]byte("secret"), WithChallenge(5, 8, 2))
|
|
cr, _ := s.CreateChallenge()
|
|
sols := solve(cr.Token, cr.Challenge)
|
|
|
|
// wrong solution
|
|
bad := append([]string(nil), sols...)
|
|
bad[0] = "999999999"
|
|
if s.Redeem(cr.Token, bad).Success {
|
|
t.Error("wrong solution accepted")
|
|
}
|
|
// wrong count — use a fresh challenge so this exercises the count check
|
|
// itself, not the consumed-nonce path from the wrong-solution redeem above.
|
|
crc, _ := s.CreateChallenge()
|
|
solsc := solve(crc.Token, crc.Challenge)
|
|
if s.Redeem(crc.Token, solsc[:len(solsc)-1]).Success {
|
|
t.Error("short solution list accepted")
|
|
}
|
|
// replay: first redeem consumes the nonce
|
|
cr2, _ := s.CreateChallenge()
|
|
sols2 := solve(cr2.Token, cr2.Challenge)
|
|
if !s.Redeem(cr2.Token, sols2).Success {
|
|
t.Fatal("first redeem should succeed")
|
|
}
|
|
if s.Redeem(cr2.Token, sols2).Success {
|
|
t.Error("replayed redeem accepted")
|
|
}
|
|
}
|