feat(captcha): SetSecret for live HMAC secret rotation

Server now holds the secret in an atomic.Pointer so a running instance can
swap it; outstanding challenge and verification tokens signed with the old
secret immediately fail verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-20 14:20:43 +08:00
parent accb305fa3
commit 742f4434b1

View File

@ -10,6 +10,7 @@ import (
"encoding/hex"
"net/http"
"strconv"
"sync/atomic"
"time"
)
@ -34,7 +35,7 @@ type RedeemResponse struct {
}
type Server struct {
secret []byte
secret atomic.Pointer[[]byte]
count, size, d int
challengeTTL time.Duration
tokenTTL time.Duration
@ -59,7 +60,6 @@ func WithUsedTokenStore(ns NonceStore) Option { return func(sv *Server) { sv.use
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server {
s := &Server{
secret: secret,
count: 50,
size: 32,
d: 4,
@ -68,12 +68,20 @@ func New(secret []byte, opts ...Option) *Server {
nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(),
}
s.secret.Store(&secret)
for _, o := range opts {
o(s)
}
return s
}
func (s *Server) secretBytes() []byte { return *s.secret.Load() }
// SetSecret swaps the HMAC secret at runtime. Outstanding challenge and
// verification tokens signed with the old secret immediately fail
// verification, so callers can use this to invalidate all issued tokens.
func (s *Server) SetSecret(secret []byte) { s.secret.Store(&secret) }
func (s *Server) CreateChallenge() (ChallengeResponse, error) {
buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil {
@ -81,7 +89,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
}
nonce := hex.EncodeToString(buf)
expires := nowMs() + s.challengeTTL.Milliseconds()
token := makeChallengeToken(s.secret, nonce, expires, s.count, s.size, s.d)
token := makeChallengeToken(s.secretBytes(), nonce, expires, s.count, s.size, s.d)
return ChallengeResponse{
Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token,
@ -90,7 +98,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
}
func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secret, token)
claims := verifyChallengeToken(s.secretBytes(), token)
if claims == nil {
return RedeemResponse{Success: false}
}
@ -111,7 +119,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
}
}
expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secret, expires)
vt, err := makeVerificationToken(s.secretBytes(), expires)
if err != nil {
return RedeemResponse{Success: false}
}
@ -132,7 +140,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
// fragment that still carries the widget (with its reset flow) lets the visitor
// re-solve and retry — callers MUST keep the widget present on rejection paths.
func (s *Server) VerifyToken(token string) bool {
claims := parseVerificationToken(s.secret, token)
claims := parseVerificationToken(s.secretBytes(), token)
if claims == nil {
return false
}