Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of ARI #286

Merged
merged 3 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 45 additions & 32 deletions acmeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,44 +137,21 @@ func (iss *ACMEIssuer) newACMEClientWithAccount(ctx context.Context, useTestCA,
// independent of any particular ACME account. If useTestCA is true, am.TestCA
// will be used if it is set; otherwise, the primary CA will be used.
func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) {
// ensure defaults are filled in
var caURL string
if useTestCA {
caURL = iss.TestCA
}
if caURL == "" {
caURL = iss.CA
client, err := iss.newBasicACMEClient()
if err != nil {
return nil, err
}
if caURL == "" {
caURL = DefaultACME.CA

// fill in a little more beyond a basic client
if useTestCA && iss.TestCA != "" {
client.Client.Directory = iss.TestCA
}
certObtainTimeout := iss.CertObtainTimeout
if certObtainTimeout == 0 {
certObtainTimeout = DefaultACME.CertObtainTimeout
}

// ensure endpoint is secure (assume HTTPS if scheme is missing)
if !strings.Contains(caURL, "://") {
caURL = "https://" + caURL
}
u, err := url.Parse(caURL)
if err != nil {
return nil, err
}
if u.Scheme != "https" && !SubjectIsInternal(u.Host) {
return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required for non-internal CA)", caURL)
}

client := &acmez.Client{
Client: &acme.Client{
Directory: caURL,
PollTimeout: certObtainTimeout,
UserAgent: buildUAString(),
HTTPClient: iss.httpClient,
},
ChallengeSolvers: make(map[string]acmez.Solver),
}
client.Logger = iss.Logger.Named("acme_client")
client.Client.PollTimeout = certObtainTimeout
client.ChallengeSolvers = make(map[string]acmez.Solver)

// configure challenges (most of the time, DNS challenge is
// exclusive of other ones because it is usually only used
Expand Down Expand Up @@ -230,6 +207,42 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) {
return client, nil
}

// newBasicACMEClient sets up a basically-functional ACME client that is not capable
// of solving challenges but can provide basic interactions with the server.
func (iss *ACMEIssuer) newBasicACMEClient() (*acmez.Client, error) {
caURL := iss.CA
if caURL == "" {
caURL = DefaultACME.CA
}
// ensure endpoint is secure (assume HTTPS if scheme is missing)
if !strings.Contains(caURL, "://") {
caURL = "https://" + caURL
}
u, err := url.Parse(caURL)
if err != nil {
return nil, err
}
if u.Scheme != "https" && !SubjectIsInternal(u.Host) {
return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required for non-internal CA)", caURL)
}
return &acmez.Client{
Client: &acme.Client{
Directory: caURL,
UserAgent: buildUAString(),
HTTPClient: iss.httpClient,
Logger: iss.Logger.Named("acme_client"),
},
}, nil
}

func (iss *ACMEIssuer) getRenewalInfo(ctx context.Context, cert Certificate) (acme.RenewalInfo, error) {
acmeClient, err := iss.newBasicACMEClient()
if err != nil {
return acme.RenewalInfo{}, err
}
return acmeClient.GetRenewalInfo(ctx, cert.Certificate.Leaf)
}

func (iss *ACMEIssuer) getHTTPPort() int {
useHTTPPort := HTTPChallengePort
if HTTPPort > 0 && HTTPPort != HTTPChallengePort {
Expand Down
7 changes: 7 additions & 0 deletions acmeissuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ func (am *ACMEIssuer) doIssue(ctx context.Context, csr *x509.CertificateRequest,
if am.NotAfter != 0 {
params.NotAfter = time.Now().Add(am.NotAfter)
}
if replacing, ok := ctx.Value(ctxKeyARIReplaces).(*x509.Certificate); ok {
params.Replaces = replacing
}

// do this in a loop because there's an error case that may necessitate a retry, but not more than once
var certChains []acme.Certificate
Expand Down Expand Up @@ -631,6 +634,10 @@ const (
// prefixACME is the storage key prefix used for ACME-specific assets.
const prefixACME = "acme"

type ctxKey string

const ctxKeyARIReplaces = ctxKey("ari_replaces")

// Interface guards
var (
_ PreChecker = (*ACMEIssuer)(nil)
Expand Down
79 changes: 67 additions & 12 deletions certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"math/rand"
"net"
"os"
"strings"
"time"

"github.com/mholt/acmez/v2/acme"
"go.uber.org/zap"
"golang.org/x/crypto/ocsp"
)
Expand Down Expand Up @@ -56,6 +59,9 @@ type Certificate struct {

// The unique string identifying the issuer of this certificate.
issuerKey string

// ACME Renewal Information, if available
ari acme.RenewalInfo
}

// Empty returns true if the certificate struct is not filled out; at
Expand All @@ -67,10 +73,44 @@ func (cert Certificate) Empty() bool {
// Hash returns a checksum of the certificate chain's DER-encoded bytes.
func (cert Certificate) Hash() string { return cert.hash }

// NeedsRenewal returns true if the certificate is
// expiring soon (according to cfg) or has expired.
// NeedsRenewal returns true if the certificate is expiring
// soon (according to ARI and/or cfg) or has expired.
func (cert Certificate) NeedsRenewal(cfg *Config) bool {
return currentlyInRenewalWindow(cert.Leaf.NotBefore, expiresAt(cert.Leaf), cfg.RenewalWindowRatio)
return certNeedsRenewal(cfg, cert.Leaf, cert.ari)
}

func certNeedsRenewal(cfg *Config, leaf *x509.Certificate, ari acme.RenewalInfo) bool {
// first check ARI: if it says it's time to renew, it's time to renew
selectedTime := ari.SelectedTime
if selectedTime.IsZero() &&
(!ari.SuggestedWindow.Start.IsZero() && !ari.SuggestedWindow.End.IsZero()) {
// if for some reason a random time in the window hasn't been selected yet, we
// can always improvise one, even if this is called multiple times; many random
// times isn't really any different from one :D (code borrowed from our acme package)
start, end := ari.SuggestedWindow.Start.Unix()+1, ari.SuggestedWindow.End.Unix()
selectedTime = time.Unix(rand.Int63n(end-start)+start, 0).UTC()
}
if !selectedTime.IsZero() {
// ARI spec recommends an algorithm that renews after the randomly-selected
// time OR just before it if the next waking time would be after it; this
// cutoff can actually be before the start of the renewal window, but the spec
// author says that's OK: https://github.com/aarongable/draft-acme-ari/issues/71
cutoff := ari.SelectedTime.Add(-cfg.certCache.options.RenewCheckInterval)
if time.Now().After(cutoff) {
return true
}

// according to ARI, we are not ready to renew; however, for redundancy
// do not rely solely on ARI calculations... give credence to the expiration
// date; in fact, I want to ignore ARI if we are past a "dangerously close"
// limit, to avoid any possibility of a bug in ARI compromising a site's
// uptime: we should always always always give heed to actual validity period
if currentlyInRenewalWindow(leaf.NotBefore, expiresAt(leaf), 9.0/10.0) {
return true
}
}
// if ARI is not available, just use expiration date as a guide
return currentlyInRenewalWindow(leaf.NotBefore, expiresAt(leaf), cfg.RenewalWindowRatio)
}

// Expired returns true if the certificate has expired.
Expand All @@ -85,8 +125,8 @@ func (cert Certificate) Expired() bool {
return time.Now().After(expiresAt(cert.Leaf))
}

// currentlyInRenewalWindow returns true if the current time is
// within the renewal window, according to the given start/end
// currentlyInRenewalWindow returns true if the current time is within
// (or after) the renewal window, according to the given start/end
// dates and the ratio of the renewal window. If true is returned,
// the certificate being considered is due for renewal.
func currentlyInRenewalWindow(notBefore, notAfter time.Time, renewalWindowRatio float64) bool {
Expand Down Expand Up @@ -154,9 +194,23 @@ func (cfg *Config) loadManagedCertificate(ctx context.Context, domain string) (C
}
cert.managed = true
cert.issuerKey = certRes.issuerKey
if ari, err := certRes.getARI(); err == nil && ari != nil {
cert.ari = *ari
}
return cert, nil
}

// getARI unpacks ACME Renewal Information from the issuer data, if available.
// It is only an error if there is invalid JSON.
func (certRes CertificateResource) getARI() (*acme.RenewalInfo, error) {
if len(certRes.IssuerData) == 0 {
return nil, nil
}
var acmeCert acme.Certificate
err := json.Unmarshal(certRes.IssuerData, &acmeCert)
return acmeCert.RenewalInfo, err
}

// CacheUnmanagedCertificatePEMFile loads a certificate for host using certFile
// and keyFile, which must be in PEM format. It stores the certificate in
// the in-memory cache and returns the hash, useful for removing from the cache.
Expand Down Expand Up @@ -329,21 +383,22 @@ func fillCertFromLeaf(cert *Certificate, tlsCert tls.Certificate) error {
return nil
}

// managedCertInStorageExpiresSoon returns true if cert (being a
// managed certificate) is expiring within RenewDurationBefore.
// It returns false if there was an error checking the expiration
// of the certificate as found in storage, or if the certificate
// in storage is NOT expiring soon. A certificate that is expiring
// managedCertInStorageNeedsRenewal returns true if cert (being a
// managed certificate) is expiring soon (according to cfg) or if
// ACME Renewal Information (ARI) is available and says that it is
// time to renew (it uses existing ARI; it does not update it).
// It returns false if there was an error, the cert is not expiring
// soon, and ARI window is still future. A certificate that is expiring
// soon in our cache but is not expiring soon in storage probably
// means that another instance renewed the certificate in the
// meantime, and it would be a good idea to simply load the cert
// into our cache rather than repeating the renewal process again.
func (cfg *Config) managedCertInStorageExpiresSoon(ctx context.Context, cert Certificate) (bool, error) {
func (cfg *Config) managedCertInStorageNeedsRenewal(ctx context.Context, cert Certificate) (bool, error) {
certRes, err := cfg.loadCertResourceAnyIssuer(ctx, cert.Names[0])
if err != nil {
return false, err
}
_, needsRenew := cfg.managedCertNeedsRenewal(certRes)
_, _, needsRenew := cfg.managedCertNeedsRenewal(certRes)
return needsRenew, nil
}

Expand Down
6 changes: 4 additions & 2 deletions certmagic.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"log"
"net"
Expand Down Expand Up @@ -388,7 +389,8 @@ type IssuedCertificate struct {
Certificate []byte

// Any extra information to serialize alongside the
// certificate in storage.
// certificate in storage. It MUST be serializable
// as JSON in order to be preserved.
Metadata any
}

Expand All @@ -409,7 +411,7 @@ type CertificateResource struct {

// Any extra information associated with the certificate,
// usually provided by the issuer implementation.
IssuerData any `json:"issuer_data,omitempty"`
IssuerData json.RawMessage `json:"issuer_data,omitempty"`

// The unique string identifying the issuer of the
// certificate; internally useful for storage access.
Expand Down
32 changes: 24 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,11 +639,15 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool
issuerKey := issuerUsed.IssuerKey()

// success - immediately save the certificate resource
metaJSON, err := json.Marshal(issuedCert.Metadata)
if err != nil {
log.Error("unable to encode certificate metadata", zap.Error(err))
}
certRes := CertificateResource{
SANs: namesFromCSR(csr),
CertificatePEM: issuedCert.Certificate,
PrivateKeyPEM: privKeyPEM,
IssuerData: issuedCert.Metadata,
IssuerData: metaJSON,
issuerKey: issuerUsed.IssuerKey(),
}
err = cfg.saveCertResource(ctx, issuerUsed, certRes)
Expand Down Expand Up @@ -792,7 +796,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
}

// check if renew is still needed - might have been renewed while waiting for lock
timeLeft, needsRenew := cfg.managedCertNeedsRenewal(certRes)
timeLeft, leaf, needsRenew := cfg.managedCertNeedsRenewal(certRes)
if !needsRenew {
if force {
log.Info("certificate does not need to be renewed, but renewal is being forced",
Expand Down Expand Up @@ -869,6 +873,9 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
}
}

// the ACME client will use this to tell the server we are replacing a certificate
ctx = context.WithValue(ctx, ctxKeyARIReplaces, leaf)
mholt marked this conversation as resolved.
Show resolved Hide resolved

issuedCert, err = issuer.Issue(ctx, useCSR)
if err == nil {
issuerUsed = issuer
Expand Down Expand Up @@ -902,11 +909,15 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
issuerKey := issuerUsed.IssuerKey()

// success - immediately save the renewed certificate resource
metaJSON, err := json.Marshal(issuedCert.Metadata)
if err != nil {
log.Error("unable to encode certificate metadata", zap.Error(err))
}
newCertRes := CertificateResource{
SANs: namesFromCSR(csr),
CertificatePEM: issuedCert.Certificate,
PrivateKeyPEM: certRes.PrivateKeyPEM,
IssuerData: issuedCert.Metadata,
IssuerData: metaJSON,
issuerKey: issuerKey,
}
err = cfg.saveCertResource(ctx, issuerUsed, newCertRes)
Expand Down Expand Up @@ -1206,14 +1217,19 @@ func (cfg *Config) lockKey(op, domainName string) string {

// managedCertNeedsRenewal returns true if certRes is expiring soon or already expired,
// or if the process of decoding the cert and checking its expiration returned an error.
func (cfg *Config) managedCertNeedsRenewal(certRes CertificateResource) (time.Duration, bool) {
// If there wasn't an error, the leaf cert is also returned, so it can be reused if
// necessary, since we are parsing the PEM bundle anyway.
func (cfg *Config) managedCertNeedsRenewal(certRes CertificateResource) (time.Duration, *x509.Certificate, bool) {
certChain, err := parseCertsFromPEMBundle(certRes.CertificatePEM)
if err != nil {
return 0, true
if err != nil || len(certChain) == 0 {
return 0, nil, true
}
var ari acme.RenewalInfo
if ariPtr, err := certRes.getARI(); err == nil && ariPtr != nil {
ari = *ariPtr
}
remaining := time.Until(expiresAt(certChain[0]))
needsRenew := currentlyInRenewalWindow(certChain[0].NotBefore, expiresAt(certChain[0]), cfg.RenewalWindowRatio)
return remaining, needsRenew
return remaining, certChain[0], certNeedsRenewal(cfg, certChain[0], ari)
}

func (cfg *Config) emit(ctx context.Context, eventName string, data map[string]any) error {
Expand Down
Loading
Loading