From 742f4434b19395820f3253000cfefcb38af35197 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Mon, 20 Jul 2026 14:20:43 +0800 Subject: [PATCH] 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 --- captcha/captcha.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/captcha/captcha.go b/captcha/captcha.go index 04b8650..d6825c8 100644 --- a/captcha/captcha.go +++ b/captcha/captcha.go @@ -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 }