Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

225 lines
7.8 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-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/<cert-id>. 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 <aki>.<serial>")
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
}