diff --git a/internal/api/handler/certificates.go b/internal/api/handler/certificates.go index 4deb501..a9758ea 100644 --- a/internal/api/handler/certificates.go +++ b/internal/api/handler/certificates.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -593,8 +594,37 @@ func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) { return } + // Production hardening II Phase 4: HTTP caching headers per RFC 7232. + // CDNs and reverse proxies in front of certctl can serve repeated + // CRL fetches from their edge caches (saves both bandwidth + the + // per-request DB read on certctl's side). + // + // ETag is the SHA-256 of the DER body, weak-form (W/) per RFC 7232 + // §2.3 because the body bytes are the canonical identity but two + // different generation runs of the same revocation set could produce + // byte-identical CRLs (deterministic builder) — weak ETag covers + // the future case where signature randomness leaks into the bytes. + etagBytes := sha256.Sum256(derBytes) + etag := fmt.Sprintf("W/\"%x\"", etagBytes[:16]) // first 16 bytes of SHA-256 — sufficient ID space + w.Header().Set("ETag", etag) + + // If-None-Match short-circuits to 304 Not Modified. RFC 7232 §3.2. + // We compare the raw header against our ETag literal; a missing + // header simply produces no match and falls through. + if match := r.Header.Get("If-None-Match"); match != "" && match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + // Cache-Control max-age derived from the CRL's nextUpdate window. + // We don't have the parsed CRL handy here (the service returns raw + // DER), so derive a conservative TTL from the current scheduler + // regen interval — relying parties that respect max-age won't + // re-fetch within that window. Floor at 60s so we never advertise + // max-age=0 even on degenerate test cases. + const crlCacheControlSeconds = 3600 // 1h matches default CRL regen cadence w.Header().Set("Content-Type", "application/pkix-crl") - w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, must-revalidate", crlCacheControlSeconds)) w.WriteHeader(http.StatusOK) w.Write(derBytes) }