diff options
Diffstat (limited to 'vendor/github.com/mholt/acmez/client.go')
-rw-r--r-- | vendor/github.com/mholt/acmez/client.go | 656 |
1 files changed, 656 insertions, 0 deletions
diff --git a/vendor/github.com/mholt/acmez/client.go b/vendor/github.com/mholt/acmez/client.go new file mode 100644 index 0000000000..4cad9c5e57 --- /dev/null +++ b/vendor/github.com/mholt/acmez/client.go @@ -0,0 +1,656 @@ +// Copyright 2020 Matthew Holt +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package acmez implements the higher-level flow of the ACME specification, +// RFC 8555: https://tools.ietf.org/html/rfc8555, specifically the sequence +// in Section 7.1 (page 21). +// +// It makes it easy to obtain certificates with various challenge types +// using pluggable challenge solvers, and provides some handy utilities for +// implementing solvers and using the certificates. It DOES NOT manage +// certificates, it only gets them from the ACME server. +// +// NOTE: This package's main function is to get a certificate, not manage it. +// Most users will want to *manage* certificates over the lifetime of a +// long-running program such as a HTTPS or TLS server, and should use CertMagic +// instead: https://github.com/caddyserver/certmagic. +package acmez + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/x509" + "errors" + "fmt" + weakrand "math/rand" + "net" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/mholt/acmez/acme" + "go.uber.org/zap" + "golang.org/x/net/idna" +) + +func init() { + weakrand.Seed(time.Now().UnixNano()) +} + +// Client is a high-level API for ACME operations. It wraps +// a lower-level ACME client with useful functions to make +// common flows easier, especially for the issuance of +// certificates. +type Client struct { + *acme.Client + + // Map of solvers keyed by name of the challenge type. + ChallengeSolvers map[string]Solver + + // An optional logger. Default: no logs + Logger *zap.Logger +} + +// ObtainCertificateUsingCSR obtains all resulting certificate chains using the given CSR, which +// must be completely and properly filled out (particularly its DNSNames and Raw fields - this +// usually involves creating a template CSR, then calling x509.CreateCertificateRequest, then +// x509.ParseCertificateRequest on the output). The Subject CommonName is NOT considered. +// +// It implements every single part of the ACME flow described in RFC 8555 §7.1 with the exception +// of "Create account" because this method signature does not have a way to return the udpated +// account object. The account's status MUST be "valid" in order to succeed. +// +// As far as SANs go, this method currently only supports DNSNames on the csr. +func (c *Client) ObtainCertificateUsingCSR(ctx context.Context, account acme.Account, csr *x509.CertificateRequest) ([]acme.Certificate, error) { + if account.Status != acme.StatusValid { + return nil, fmt.Errorf("account status is not valid: %s", account.Status) + } + if csr == nil { + return nil, fmt.Errorf("missing CSR") + } + + var ids []acme.Identifier + for _, name := range csr.DNSNames { + // "The domain name MUST be encoded in the form in which it would appear + // in a certificate. That is, it MUST be encoded according to the rules + // in Section 7 of [RFC5280]." §7.1.4 + normalizedName, err := idna.ToASCII(name) + if err != nil { + return nil, fmt.Errorf("converting identifier '%s' to ASCII: %v", name, err) + } + + ids = append(ids, acme.Identifier{ + Type: "dns", + Value: normalizedName, + }) + } + if len(ids) == 0 { + return nil, fmt.Errorf("no identifiers found") + } + + order := acme.Order{Identifiers: ids} + var err error + + // remember which challenge types failed for which identifiers + // so we can retry with other challenge types + failedChallengeTypes := make(failedChallengeMap) + + const maxAttempts = 3 // hard cap on number of retries for good measure + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + select { + case <-time.After(1 * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + // create order for a new certificate + order, err = c.Client.NewOrder(ctx, account, order) + if err != nil { + return nil, fmt.Errorf("creating new order: %w", err) + } + + // solve one challenge for each authz on the order + err = c.solveChallenges(ctx, account, order, failedChallengeTypes) + + // yay, we win! + if err == nil { + break + } + + // for some errors, we can retry with different challenge types + var problem acme.Problem + if errors.As(err, &problem) { + authz := problem.Resource.(acme.Authorization) + if c.Logger != nil { + c.Logger.Error("validating authorization", + zap.String("identifier", authz.IdentifierValue()), + zap.Error(err), + zap.String("order", order.Location), + zap.Int("attempt", attempt), + zap.Int("max_attempts", maxAttempts)) + } + err = fmt.Errorf("solving challenge: %s: %w", authz.IdentifierValue(), err) + if errors.As(err, &retryableErr{}) { + continue + } + return nil, err + } + + return nil, fmt.Errorf("solving challenges: %w (order=%s)", err, order.Location) + } + + if c.Logger != nil { + c.Logger.Info("validations succeeded; finalizing order", zap.String("order", order.Location)) + } + + // finalize the order, which requests the CA to issue us a certificate + order, err = c.Client.FinalizeOrder(ctx, account, order, csr.Raw) + if err != nil { + return nil, fmt.Errorf("finalizing order %s: %w", order.Location, err) + } + + // finally, download the certificate + certChains, err := c.Client.GetCertificateChain(ctx, account, order.Certificate) + if err != nil { + return nil, fmt.Errorf("downloading certificate chain from %s: %w (order=%s)", + order.Certificate, err, order.Location) + } + + if c.Logger != nil { + if len(certChains) == 0 { + c.Logger.Info("no certificate chains offered by server") + } else { + c.Logger.Info("successfully downloaded available certificate chains", + zap.Int("count", len(certChains)), + zap.String("first_url", certChains[0].URL)) + } + } + + return certChains, nil +} + +// ObtainCertificate is the same as ObtainCertificateUsingCSR, except it is a slight wrapper +// that generates the CSR for you. Doing so requires the private key you will be using for +// the certificate (different from the account private key). It obtains a certificate for +// the given SANs (domain names) using the provided account. +func (c *Client) ObtainCertificate(ctx context.Context, account acme.Account, certPrivateKey crypto.Signer, sans []string) ([]acme.Certificate, error) { + if len(sans) == 0 { + return nil, fmt.Errorf("no DNS names provided: %v", sans) + } + if certPrivateKey == nil { + return nil, fmt.Errorf("missing certificate private key") + } + + csrTemplate := new(x509.CertificateRequest) + for _, name := range sans { + if ip := net.ParseIP(name); ip != nil { + csrTemplate.IPAddresses = append(csrTemplate.IPAddresses, ip) + } else if strings.Contains(name, "@") { + csrTemplate.EmailAddresses = append(csrTemplate.EmailAddresses, name) + } else if u, err := url.Parse(name); err == nil && strings.Contains(name, "/") { + csrTemplate.URIs = append(csrTemplate.URIs, u) + } else { + csrTemplate.DNSNames = append(csrTemplate.DNSNames, name) + } + } + + // to properly fill out the CSR, we need to create it, then parse it + csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, certPrivateKey) + if err != nil { + return nil, fmt.Errorf("generating CSR: %v", err) + } + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + return nil, fmt.Errorf("parsing generated CSR: %v", err) + } + + return c.ObtainCertificateUsingCSR(ctx, account, csr) +} + +// getAuthzObjects constructs stateful authorization objects for each authz on the order. +// It includes all authorizations regardless of their status so that they can be +// deactivated at the end if necessary. Be sure to check authz status before operating +// on the authz; not all will be "pending" - some authorizations might already be valid. +func (c *Client) getAuthzObjects(ctx context.Context, account acme.Account, order acme.Order, + failedChallengeTypes failedChallengeMap) ([]*authzState, error) { + var authzStates []*authzState + var err error + + // start by allowing each authz's solver to present for its challenge + for _, authzURL := range order.Authorizations { + authz := &authzState{account: account} + authz.Authorization, err = c.Client.GetAuthorization(ctx, account, authzURL) + if err != nil { + return nil, fmt.Errorf("getting authorization at %s: %w", authzURL, err) + } + + // add all offered challenge types to our memory if they + // arent't there already; we use this for statistics to + // choose the most successful challenge type over time; + // if initial fill, randomize challenge order + preferredChallengesMu.Lock() + preferredWasEmpty := len(preferredChallenges) == 0 + for _, chal := range authz.Challenges { + preferredChallenges.addUnique(chal.Type) + } + if preferredWasEmpty { + weakrand.Shuffle(len(preferredChallenges), func(i, j int) { + preferredChallenges[i], preferredChallenges[j] = + preferredChallenges[j], preferredChallenges[i] + }) + } + preferredChallengesMu.Unlock() + + // copy over any challenges that are not known to have already + // failed, making them candidates for solving for this authz + failedChallengeTypes.enqueueUnfailedChallenges(authz) + + authzStates = append(authzStates, authz) + } + + // sort authzs so that challenges which require waiting go first; no point + // in getting authorizations quickly while others will take a long time + sort.SliceStable(authzStates, func(i, j int) bool { + _, iIsWaiter := authzStates[i].currentSolver.(Waiter) + _, jIsWaiter := authzStates[j].currentSolver.(Waiter) + // "if i is a waiter, and j is not a waiter, then i is less than j" + return iIsWaiter && !jIsWaiter + }) + + return authzStates, nil +} + +func (c *Client) solveChallenges(ctx context.Context, account acme.Account, order acme.Order, failedChallengeTypes failedChallengeMap) error { + authzStates, err := c.getAuthzObjects(ctx, account, order, failedChallengeTypes) + if err != nil { + return err + } + + // when the function returns, make sure we clean up any and all resources + defer func() { + // always clean up any remaining challenge solvers + for _, authz := range authzStates { + if authz.currentSolver == nil { + // happens when authz state ended on a challenge we have no + // solver for or if we have already cleaned up this solver + continue + } + if err := authz.currentSolver.CleanUp(ctx, authz.currentChallenge); err != nil { + if c.Logger != nil { + c.Logger.Error("cleaning up solver", + zap.String("identifier", authz.IdentifierValue()), + zap.String("challenge_type", authz.currentChallenge.Type), + zap.Error(err)) + } + } + } + + if err == nil { + return + } + + // if this function returns with an error, make sure to deactivate + // all pending or valid authorization objects so they don't "leak" + // See: https://github.com/go-acme/lego/issues/383 and https://github.com/go-acme/lego/issues/353 + for _, authz := range authzStates { + if authz.Status != acme.StatusPending && authz.Status != acme.StatusValid { + continue + } + updatedAuthz, err := c.Client.DeactivateAuthorization(ctx, account, authz.Location) + if err != nil { + if c.Logger != nil { + c.Logger.Error("deactivating authorization", + zap.String("identifier", authz.IdentifierValue()), + zap.String("authz", authz.Location), + zap.Error(err)) + } + } + authz.Authorization = updatedAuthz + } + }() + + // present for all challenges first; this allows them all to begin any + // slow tasks up front if necessary before we start polling/waiting + for _, authz := range authzStates { + // see §7.1.6 for state transitions + if authz.Status != acme.StatusPending && authz.Status != acme.StatusValid { + return fmt.Errorf("authz %s has unexpected status; order will fail: %s", authz.Location, authz.Status) + } + if authz.Status == acme.StatusValid { + continue + } + + err = c.presentForNextChallenge(ctx, authz) + if err != nil { + return err + } + } + + // now that all solvers have had the opportunity to present, tell + // the server to begin the selected challenge for each authz + for _, authz := range authzStates { + err = c.initiateCurrentChallenge(ctx, authz) + if err != nil { + return err + } + } + + // poll each authz to wait for completion of all challenges + for _, authz := range authzStates { + err = c.pollAuthorization(ctx, account, authz, failedChallengeTypes) + if err != nil { + return err + } + } + + return nil +} + +func (c *Client) presentForNextChallenge(ctx context.Context, authz *authzState) error { + if authz.Status != acme.StatusPending { + if authz.Status == acme.StatusValid && c.Logger != nil { + c.Logger.Info("authorization already valid", + zap.String("identifier", authz.IdentifierValue()), + zap.String("authz_url", authz.Location), + zap.Time("expires", authz.Expires)) + } + return nil + } + + err := c.nextChallenge(authz) + if err != nil { + return err + } + + if c.Logger != nil { + c.Logger.Info("trying to solve challenge", + zap.String("identifier", authz.IdentifierValue()), + zap.String("challenge_type", authz.currentChallenge.Type), + zap.String("ca", c.Directory)) + } + + err = authz.currentSolver.Present(ctx, authz.currentChallenge) + if err != nil { + return fmt.Errorf("presenting for challenge: %w", err) + } + + return nil +} + +func (c *Client) initiateCurrentChallenge(ctx context.Context, authz *authzState) error { + if authz.Status != acme.StatusPending { + return nil + } + + // by now, all challenges should have had an opportunity to present, so + // if this solver needs more time to finish presenting, wait on it now + // (yes, this does block the initiation of the other challenges, but + // that's probably OK, since we can't finalize the order until the slow + // challenges are done too) + if waiter, ok := authz.currentSolver.(Waiter); ok { + err := waiter.Wait(ctx, authz.currentChallenge) + if err != nil { + return fmt.Errorf("waiting for solver %T to be ready: %w", authz.currentSolver, err) + } + } + + // tell the server to initiate the challenge + var err error + authz.currentChallenge, err = c.Client.InitiateChallenge(ctx, authz.account, authz.currentChallenge) + if err != nil { + return fmt.Errorf("initiating challenge with server: %w", err) + } + + if c.Logger != nil { + c.Logger.Debug("challenge accepted", + zap.String("identifier", authz.IdentifierValue()), + zap.String("challenge_type", authz.currentChallenge.Type)) + } + + return nil +} + +// nextChallenge sets the next challenge (and associated solver) on +// authz; it returns an error if there is no compatible challenge. +func (c *Client) nextChallenge(authz *authzState) error { + preferredChallengesMu.Lock() + defer preferredChallengesMu.Unlock() + + // find the most-preferred challenge that is also in the list of + // remaining challenges, then make sure we have a solver for it + for _, prefChalType := range preferredChallenges { + for i, remainingChal := range authz.remainingChallenges { + if remainingChal.Type != prefChalType.typeName { + continue + } + authz.currentChallenge = remainingChal + authz.currentSolver = c.ChallengeSolvers[authz.currentChallenge.Type] + if authz.currentSolver != nil { + authz.remainingChallenges = append(authz.remainingChallenges[:i], authz.remainingChallenges[i+1:]...) + return nil + } + if c.Logger != nil { + c.Logger.Debug("no solver configured", zap.String("challenge_type", remainingChal.Type)) + } + break + } + } + return fmt.Errorf("%s: no solvers available for remaining challenges (configured=%v offered=%v remaining=%v)", + authz.IdentifierValue(), c.enabledChallengeTypes(), authz.listOfferedChallenges(), authz.listRemainingChallenges()) +} + +func (c *Client) pollAuthorization(ctx context.Context, account acme.Account, authz *authzState, failedChallengeTypes failedChallengeMap) error { + // In §7.5.1, the spec says: + // + // "For challenges where the client can tell when the server has + // validated the challenge (e.g., by seeing an HTTP or DNS request + // from the server), the client SHOULD NOT begin polling until it has + // seen the validation request from the server." + // + // However, in practice, this is difficult in the general case because + // we would need to design some relatively-nuanced concurrency and hope + // that the solver implementations also get their side right -- and the + // fact that it's even possible only sometimes makes it harder, because + // each solver needs a way to signal whether we should wait for its + // approval. So no, I've decided not to implement that recommendation + // in this particular library, but any implementations that use the lower + // ACME API directly are welcome and encouraged to do so where possible. + var err error + authz.Authorization, err = c.Client.PollAuthorization(ctx, account, authz.Authorization) + + // if a challenge was attempted (i.e. did not start valid)... + if authz.currentSolver != nil { + // increment the statistics on this challenge type before handling error + preferredChallengesMu.Lock() + preferredChallenges.increment(authz.currentChallenge.Type, err == nil) + preferredChallengesMu.Unlock() + + // always clean up the challenge solver after polling, regardless of error + cleanupErr := authz.currentSolver.CleanUp(ctx, authz.currentChallenge) + if cleanupErr != nil && c.Logger != nil { + c.Logger.Error("cleaning up solver", + zap.String("identifier", authz.IdentifierValue()), + zap.String("challenge_type", authz.currentChallenge.Type), + zap.Error(err)) + } + authz.currentSolver = nil // avoid cleaning it up again later + } + + // finally, handle any error from validating the authz + if err != nil { + var problem acme.Problem + if errors.As(err, &problem) { + if c.Logger != nil { + c.Logger.Error("challenge failed", + zap.String("identifier", authz.IdentifierValue()), + zap.String("challenge_type", authz.currentChallenge.Type), + zap.Int("status_code", problem.Status), + zap.String("problem_type", problem.Type), + zap.String("error", problem.Detail)) + } + + failedChallengeTypes.rememberFailedChallenge(authz) + + switch problem.Type { + case acme.ProblemTypeConnection, + acme.ProblemTypeDNS, + acme.ProblemTypeServerInternal, + acme.ProblemTypeUnauthorized, + acme.ProblemTypeTLS: + // this error might be recoverable with another challenge type + return retryableErr{err} + } + } + return fmt.Errorf("[%s] %w", authz.Authorization.IdentifierValue(), err) + } + return nil +} + +func (c *Client) enabledChallengeTypes() []string { + enabledChallenges := make([]string, 0, len(c.ChallengeSolvers)) + for name, val := range c.ChallengeSolvers { + if val != nil { + enabledChallenges = append(enabledChallenges, name) + } + } + return enabledChallenges +} + +type authzState struct { + acme.Authorization + account acme.Account + currentChallenge acme.Challenge + currentSolver Solver + remainingChallenges []acme.Challenge +} + +func (authz authzState) listOfferedChallenges() []string { + return challengeTypeNames(authz.Challenges) +} + +func (authz authzState) listRemainingChallenges() []string { + return challengeTypeNames(authz.remainingChallenges) +} + +func challengeTypeNames(challengeList []acme.Challenge) []string { + names := make([]string, 0, len(challengeList)) + for _, chal := range challengeList { + names = append(names, chal.Type) + } + return names +} + +// TODO: possibly configurable policy? converge to most successful (current) vs. completely random + +// challengeHistory is a memory of how successful a challenge type is. +type challengeHistory struct { + typeName string + successes, total int +} + +func (ch challengeHistory) successRatio() float64 { + if ch.total == 0 { + return 1.0 + } + return float64(ch.successes) / float64(ch.total) +} + +// failedChallengeMap keeps track of failed challenge types per identifier. +type failedChallengeMap map[string][]string + +func (fcm failedChallengeMap) rememberFailedChallenge(authz *authzState) { + idKey := fcm.idKey(authz) + fcm[idKey] = append(fcm[idKey], authz.currentChallenge.Type) +} + +// enqueueUnfailedChallenges enqueues each challenge offered in authz if it +// is not known to have failed for the authz's identifier already. +func (fcm failedChallengeMap) enqueueUnfailedChallenges(authz *authzState) { + idKey := fcm.idKey(authz) + for _, chal := range authz.Challenges { + if !contains(fcm[idKey], chal.Type) { + authz.remainingChallenges = append(authz.remainingChallenges, chal) + } + } +} + +func (fcm failedChallengeMap) idKey(authz *authzState) string { + return authz.Identifier.Type + authz.IdentifierValue() +} + +// challengeTypes is a list of challenges we've seen and/or +// used previously. It sorts from most successful to least +// successful, such that most successful challenges are first. +type challengeTypes []challengeHistory + +// Len is part of sort.Interface. +func (ct challengeTypes) Len() int { return len(ct) } + +// Swap is part of sort.Interface. +func (ct challengeTypes) Swap(i, j int) { ct[i], ct[j] = ct[j], ct[i] } + +// Less is part of sort.Interface. It sorts challenge +// types from highest success ratio to lowest. +func (ct challengeTypes) Less(i, j int) bool { + return ct[i].successRatio() > ct[j].successRatio() +} + +func (ct *challengeTypes) addUnique(challengeType string) { + for _, c := range *ct { + if c.typeName == challengeType { + return + } + } + *ct = append(*ct, challengeHistory{typeName: challengeType}) +} + +func (ct challengeTypes) increment(challengeType string, successful bool) { + defer sort.Stable(ct) // keep most successful challenges in front + for i, c := range ct { + if c.typeName == challengeType { + ct[i].total++ + if successful { + ct[i].successes++ + } + return + } + } +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// retryableErr wraps an error that indicates the caller should retry +// the operation; specifically with a different challenge type. +type retryableErr struct{ error } + +func (re retryableErr) Unwrap() error { return re.error } + +// Keep a list of challenges we've seen offered by servers, +// and prefer keep an ordered list of +var ( + preferredChallenges challengeTypes + preferredChallengesMu sync.Mutex +) |