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:
parent
accb305fa3
commit
742f4434b1
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user