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 { mu sync.Mutex nonces map[string]time.Time issued int } // 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) } } }