Files
metacrypt/internal/acme/nonce.go
2026-03-15 10:16:28 -07:00

72 lines
1.6 KiB
Go

package acme
import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
)
const nonceLifetime = 10 * time.Minute
// NonceStore is a thread-safe single-use nonce store with expiry.
// Nonces are short-lived per RFC 8555 §7.2.
type NonceStore struct {
nonces map[string]time.Time
issued int
mu sync.Mutex
}
// NewNonceStore creates a new nonce store.
func NewNonceStore() *NonceStore {
return &NonceStore{
nonces: make(map[string]time.Time),
}
}
// Issue generates, stores, and returns a new base64url-encoded nonce.
func (s *NonceStore) Issue() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
nonce := base64.RawURLEncoding.EncodeToString(b)
s.mu.Lock()
defer s.mu.Unlock()
s.nonces[nonce] = time.Now().Add(nonceLifetime)
s.issued++
// Purge expired nonces every 100 issues to bound memory.
if s.issued%100 == 0 {
s.purgeExpiredLocked()
}
return nonce, nil
}
// Consume validates that the nonce exists and has not expired, then removes it.
// Returns an error if the nonce is unknown, expired, or already consumed.
func (s *NonceStore) Consume(nonce string) error {
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.nonces[nonce]
if !ok {
return errors.New("unknown or already-consumed nonce")
}
delete(s.nonces, nonce)
if time.Now().After(exp) {
return errors.New("nonce expired")
}
return nil
}
// purgeExpiredLocked removes all expired nonces. Caller must hold s.mu.
func (s *NonceStore) purgeExpiredLocked() {
now := time.Now()
for n, exp := range s.nonces {
if now.After(exp) {
delete(s.nonces, n)
}
}
}