mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:31:37 +00:00
21aeed4f4e
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
225 lines
7.8 KiB
Go
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
|
|
}
|