// Copyright (c) certctl // SPDX-License-Identifier: BSL-1.1 package acme import ( "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "fmt" "math/big" "strings" "time" "github.com/certctl-io/certctl/internal/domain" ) // Phase 4 — RFC 9773 ACME Renewal Information. // // RFC 9773 §4.1: a client computes the cert-id as // // base64url-no-pad(authorityKeyIdentifier) || "." || base64url-no-pad(serial) // // and GETs /acme/.../renewal-info/. The server responds with a // JSON document carrying a `suggestedWindow` (start, end) the client // SHOULD plan its renewal inside, plus an optional `explanationURL`. // Response also carries a Retry-After header (RFC 9773 §4.2) hinting // at the next-poll cadence. // // This file: // // - parses the cert-id wire format → (akiBytes, serialBytes). // - converts the serial bytes to a hex string in the canonical // certctl shape (lowercase, no leading zeros, matching how // internal/repository/postgres/certificate.go stores them). // - computes the suggested-window from a cert's NotAfter and an // optional bound RenewalPolicy (last 33% of validity if no policy // is bound). // RenewalInfoResponse is the JSON document returned by the renewal- // info endpoint per RFC 9773 §4.1. type RenewalInfoResponse struct { SuggestedWindow RenewalWindow `json:"suggestedWindow"` ExplanationURL string `json:"explanationURL,omitempty"` } // RenewalWindow is the embedded {start, end} pair. RFC 9773 mandates // start ≤ end; the server is responsible for emitting RFC 3339 UTC // timestamps. type RenewalWindow struct { Start time.Time `json:"start"` End time.Time `json:"end"` } // ARICertID is the parsed shape of an RFC 9773 §4.1 cert-id — // authorityKeyIdentifier and serial bytes after base64url-no-pad // decoding. Callers compare against the certificate they already have // in the database; AKI is informational on the server side because // certctl's serial-uniqueness invariant is per-issuer. type ARICertID struct { // AKI is the raw bytes of the certificate's authorityKeyIdentifier // extension. AKI []byte // Serial is the raw bytes of the certificate's serial number, in // big-endian unsigned-integer form. Serial []byte } // SerialHex returns the canonical certctl-shape hex representation of // the serial number — lowercase, no leading zeros (matches what's // stored in certificate_versions.serial_number). func (a ARICertID) SerialHex() string { if len(a.Serial) == 0 { return "" } n := new(big.Int).SetBytes(a.Serial) if n.Sign() == 0 { return "0" } return strings.ToLower(n.Text(16)) } // AKIHex returns the AKI as a lowercase hex string. Useful for logging // + future per-AKI lookup paths. func (a ARICertID) AKIHex() string { return strings.ToLower(hex.EncodeToString(a.AKI)) } // Sentinel errors. ChooseProblem in writeServiceError translates the // not-found cases to RFC 7807 + RFC 8555 §6.7 problems. var ( ErrARICertIDMalformed = errors.New("acme ari: cert-id is not .") ErrARICertIDDecodeAKI = errors.New("acme ari: cert-id AKI is not valid base64url") ErrARICertIDDecodeSeria = errors.New("acme ari: cert-id serial is not valid base64url") ErrARICertIDEmpty = errors.New("acme ari: cert-id has empty AKI or serial") ) // ParseARICertID decodes an RFC 9773 §4.1 cert-id. The wire format is // strictly base64url-NO-PADDING; rfc9773 §4.1 forbids regular base64. // // Common malformations: // - missing or extra `.` separator → ErrARICertIDMalformed. // - either side fails base64url decode → ErrARICertIDDecode*. // - either side decodes to empty → ErrARICertIDEmpty. func ParseARICertID(certID string) (*ARICertID, error) { parts := strings.Split(certID, ".") if len(parts) != 2 { return nil, fmt.Errorf("%w: got %d parts", ErrARICertIDMalformed, len(parts)) } if parts[0] == "" || parts[1] == "" { return nil, ErrARICertIDEmpty } aki, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, fmt.Errorf("%w: %v", ErrARICertIDDecodeAKI, err) } serial, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("%w: %v", ErrARICertIDDecodeSeria, err) } if len(aki) == 0 || len(serial) == 0 { return nil, ErrARICertIDEmpty } return &ARICertID{AKI: aki, Serial: serial}, nil } // BuildARICertID is the inverse of ParseARICertID — useful for tests // and operator tools that want to construct a cert-id from a leaf cert. // // The input is the leaf certificate's PEM. We extract the // authorityKeyIdentifier extension and the serial number, then // base64url-no-pad-encode each + join with a `.`. func BuildARICertID(certPEM string) (string, error) { block, _ := pem.Decode([]byte(certPEM)) if block == nil { return "", fmt.Errorf("acme ari: pem decode failed") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return "", fmt.Errorf("acme ari: parse cert: %w", err) } if len(cert.AuthorityKeyId) == 0 { return "", fmt.Errorf("acme ari: certificate has no authorityKeyIdentifier extension") } if cert.SerialNumber == nil { return "", fmt.Errorf("acme ari: certificate has no serial number") } akiB64 := base64.RawURLEncoding.EncodeToString(cert.AuthorityKeyId) serialB64 := base64.RawURLEncoding.EncodeToString(cert.SerialNumber.Bytes()) return akiB64 + "." + serialB64, nil } // ComputeRenewalWindow returns the RFC 9773 suggestedWindow for a // (cert, optional renewal-policy) pair. // // Algorithm: // // - When policy is non-nil and policy.RenewalWindowDays > 0: the // window starts at NotAfter - RenewalWindowDays + spans half of // RenewalWindowDays. So a 30-day-renewal-window cert with NotAfter // 2026-06-30 emits start=2026-05-31, end=2026-06-15. This matches // boulder's default ARI behavior + ensures a Let's-Encrypt-shaped // client can plan its renewals exactly inside our renewal window. // - When policy is nil OR RenewalWindowDays ≤ 0: the window is the // last 33% of validity. So a cert with NotBefore 2026-01-01 + // NotAfter 2026-04-01 (90d validity) emits start=2026-03-01 (30d // before expiry), end=2026-03-21 (10d before expiry). // - When the cert is past NotAfter: the window starts at "now" and // ends at "now + 1 day" so a client polling on an expired cert // gets a "renew immediately" answer rather than a window in the // past. // // Returns (start, end). start ≤ end is invariant. func ComputeRenewalWindow(cert *domain.ManagedCertificate, version *domain.CertificateVersion, policy *domain.RenewalPolicy, now time.Time) (time.Time, time.Time) { if cert == nil { return time.Time{}, time.Time{} } notAfter := cert.ExpiresAt.UTC() notBefore := notAfter if version != nil && !version.NotBefore.IsZero() { notBefore = version.NotBefore.UTC() } // Past expiry: emit a 1-day "renew now" window. if !now.IsZero() && now.UTC().After(notAfter) { nowUTC := now.UTC() return nowUTC, nowUTC.Add(24 * time.Hour) } if policy != nil && policy.RenewalWindowDays > 0 { windowDays := time.Duration(policy.RenewalWindowDays) * 24 * time.Hour start := notAfter.Add(-windowDays) end := start.Add(windowDays / 2) // Defensive: never emit start in the past from "now". if !now.IsZero() && start.Before(now.UTC()) { start = now.UTC() } if end.Before(start) { end = start } return start, end } // No policy → last 33% of validity. validity := notAfter.Sub(notBefore) if validity <= 0 { // Degenerate cert (nb >= na). Use a 1-day default window // ending at notAfter. return notAfter.Add(-24 * time.Hour), notAfter } thirty3 := validity / 3 start := notAfter.Add(-thirty3) // End is 1/3 before expiry → midpoint of the renewal third. end := notAfter.Add(-thirty3 / 3) if !now.IsZero() && start.Before(now.UTC()) { start = now.UTC() } if end.Before(start) { end = start } return start, end }