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" "encoding/hex"
"net/http" "net/http"
"strconv" "strconv"
"sync/atomic"
"time" "time"
) )
@ -34,7 +35,7 @@ type RedeemResponse struct {
} }
type Server struct { type Server struct {
secret []byte secret atomic.Pointer[[]byte]
count, size, d int count, size, d int
challengeTTL time.Duration challengeTTL time.Duration
tokenTTL 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. // 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server { func New(secret []byte, opts ...Option) *Server {
s := &Server{ s := &Server{
secret: secret,
count: 50, count: 50,
size: 32, size: 32,
d: 4, d: 4,
@ -68,12 +68,20 @@ func New(secret []byte, opts ...Option) *Server {
nonces: NewMemoryNonceStore(), nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(), usedTokens: NewMemoryNonceStore(),
} }
s.secret.Store(&secret)
for _, o := range opts { for _, o := range opts {
o(s) o(s)
} }
return 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) { func (s *Server) CreateChallenge() (ChallengeResponse, error) {
buf := make([]byte, 25) buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
@ -81,7 +89,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
nonce := hex.EncodeToString(buf) nonce := hex.EncodeToString(buf)
expires := nowMs() + s.challengeTTL.Milliseconds() 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{ return ChallengeResponse{
Challenge: Challenge{C: s.count, S: s.size, D: s.d}, Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token, Token: token,
@ -90,7 +98,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
func (s *Server) Redeem(token string, solutions []string) RedeemResponse { func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secret, token) claims := verifyChallengeToken(s.secretBytes(), token)
if claims == nil { if claims == nil {
return RedeemResponse{Success: false} return RedeemResponse{Success: false}
} }
@ -111,7 +119,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
} }
} }
expires := nowMs() + s.tokenTTL.Milliseconds() expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secret, expires) vt, err := makeVerificationToken(s.secretBytes(), expires)
if err != nil { if err != nil {
return RedeemResponse{Success: false} 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 // 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. // re-solve and retry — callers MUST keep the widget present on rejection paths.
func (s *Server) VerifyToken(token string) bool { func (s *Server) VerifyToken(token string) bool {
claims := parseVerificationToken(s.secret, token) claims := parseVerificationToken(s.secretBytes(), token)
if claims == nil { if claims == nil {
return false return false
} }