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"
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user