diff --git a/httphandler.go b/httphandlers.go similarity index 61% rename from httphandler.go rename to httphandlers.go index aa65e67..f2dde42 100644 --- a/httphandler.go +++ b/httphandlers.go @@ -16,6 +16,7 @@ package certmagic import ( "net/http" + "net/url" "strings" "github.com/mholt/acmez/v2/acme" @@ -91,7 +92,7 @@ func solveHTTPChallenge(logger *zap.Logger, w http.ResponseWriter, r *http.Reque challengeReqPath := challenge.HTTP01ResourcePath() if r.URL.Path == challengeReqPath && strings.EqualFold(hostOnly(r.Host), challenge.Identifier.Value) && // mitigate DNS rebinding attacks - r.Method == "GET" { + r.Method == http.MethodGet { w.Header().Add("Content-Type", "text/plain") w.Write([]byte(challenge.KeyAuthorization)) r.Close = true @@ -116,7 +117,94 @@ func SolveHTTPChallenge(logger *zap.Logger, w http.ResponseWriter, r *http.Reque // LooksLikeHTTPChallenge returns true if r looks like an ACME // HTTP challenge request from an ACME server. func LooksLikeHTTPChallenge(r *http.Request) bool { - return r.Method == "GET" && strings.HasPrefix(r.URL.Path, challengeBasePath) + return r.Method == http.MethodGet && + strings.HasPrefix(r.URL.Path, acmeHTTPChallengeBasePath) } -const challengeBasePath = "/.well-known/acme-challenge" +// LooksLikeZeroSSLHTTPValidation returns true if the request appears to be +// domain validation from a ZeroSSL/Sectigo CA. NOTE: This API is +// non-standard and is subject to change. +func LooksLikeZeroSSLHTTPValidation(r *http.Request) bool { + return r.Method == http.MethodGet && + strings.HasPrefix(r.URL.Path, zerosslHTTPValidationBasePath) +} + +// HTTPValidationHandler wraps the ZeroSSL HTTP validation handler such that +// it can pass verification checks from ZeroSSL's API. +// +// If a request is not a ZeroSSL HTTP validation request, h will be invoked. +func (iss *ZeroSSLIssuer) HTTPValidationHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if iss.HandleZeroSSLHTTPValidation(w, r) { + return + } + h.ServeHTTP(w, r) + }) +} + +// HandleZeroSSLHTTPValidation is to ZeroSSL API HTTP validation requests like HandleHTTPChallenge +// is to ACME HTTP challenge requests. +func (iss *ZeroSSLIssuer) HandleZeroSSLHTTPValidation(w http.ResponseWriter, r *http.Request) bool { + if iss == nil { + return false + } + if !LooksLikeZeroSSLHTTPValidation(r) { + return false + } + return iss.distributedHTTPValidationAnswer(w, r) +} + +func (iss *ZeroSSLIssuer) distributedHTTPValidationAnswer(w http.ResponseWriter, r *http.Request) bool { + if iss == nil { + return false + } + logger := iss.Logger + if logger == nil { + logger = zap.NewNop() + } + host := hostOnly(r.Host) + valInfo, distributed, err := iss.getDistributedValidationInfo(r.Context(), host) + if err != nil { + logger.Error("looking up info for HTTP validation", + zap.String("host", host), + zap.String("remote_addr", r.RemoteAddr), + zap.String("user_agent", r.Header.Get("User-Agent")), + zap.Error(err)) + return false + } + return answerHTTPValidation(logger, w, r, valInfo, distributed) +} + +func answerHTTPValidation(logger *zap.Logger, rw http.ResponseWriter, req *http.Request, valInfo acme.Challenge, distributed bool) bool { + // ensure URL matches + validationURL, err := url.Parse(valInfo.URL) + if err != nil { + logger.Error("got invalid URL from CA", + zap.String("file_validation_url", valInfo.URL), + zap.Error(err)) + rw.WriteHeader(http.StatusInternalServerError) + return true + } + if req.URL.Path != validationURL.Path { + rw.WriteHeader(http.StatusNotFound) + return true + } + + rw.Header().Add("Content-Type", "text/plain") + req.Close = true + + rw.Write([]byte(valInfo.Token)) + + logger.Info("served HTTP validation credential", + zap.String("validation_path", valInfo.URL), + zap.String("challenge", "http-01"), + zap.String("remote", req.RemoteAddr), + zap.Bool("distributed", distributed)) + + return true +} + +const ( + acmeHTTPChallengeBasePath = "/.well-known/acme-challenge" + zerosslHTTPValidationBasePath = "/.well-known/pki-validation/" +) diff --git a/httphandler_test.go b/httphandlers_test.go similarity index 100% rename from httphandler_test.go rename to httphandlers_test.go diff --git a/zerosslissuer.go b/zerosslissuer.go index 10df015..5ab5cd0 100644 --- a/zerosslissuer.go +++ b/zerosslissuer.go @@ -17,10 +17,10 @@ package certmagic import ( "context" "crypto/x509" + "encoding/json" "fmt" "net" "net/http" - "net/url" "strconv" "strings" "time" @@ -68,6 +68,9 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques client := iss.getClient() identifiers := namesFromCSR(csr) + if len(identifiers) == 0 { + return nil, fmt.Errorf("no identifiers on CSR") + } logger := iss.Logger if logger == nil { @@ -104,35 +107,7 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques httpVerifier := &httpSolver{ address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())), - handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if !strings.HasPrefix(req.URL.Path, zerosslValidationPathPrefix) { - return - } - - validation, ok := cert.Validation.OtherMethods[req.Host] - if !ok { - rw.WriteHeader(http.StatusNotFound) - return - } - - // ensure URL matches - validationURL, err := url.Parse(validation.FileValidationURLHTTP) - if err != nil { - logger.Error("got invalid URL from CA", - zap.String("file_validation_url", validation.FileValidationURLHTTP), - zap.Error(err)) - rw.WriteHeader(http.StatusInternalServerError) - return - } - if req.URL.Path != validationURL.Path { - rw.WriteHeader(http.StatusNotFound) - return - } - - logger.Info("served HTTP validation file") - - fmt.Fprint(rw, strings.Join(validation.FileValidationContent, "\n")) - }), + handler: iss.HTTPValidationHandler(http.NewServeMux()), } var solver acmez.Solver = httpVerifier @@ -144,10 +119,23 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques } } - if err = solver.Present(ctx, acme.Challenge{}); err != nil { - return nil, fmt.Errorf("presenting token for verification: %v", err) + // since the distributed solver was originally designed for ACME, + // the API is geared around ACME challenges. ZeroSSL's HTTP validation + // is very similar to the HTTP challenge, but not quite compatible, + // so we kind of shim the ZeroSSL validation data into a Challenge + // object... it is not a perfect use of this type but it's pretty close + valInfo := cert.Validation.OtherMethods[identifiers[0]] + fakeChallenge := acme.Challenge{ + Identifier: acme.Identifier{ + Value: identifiers[0], // used for storage key + }, + URL: valInfo.FileValidationURLHTTP, + Token: strings.Join(cert.Validation.OtherMethods[identifiers[0]].FileValidationContent, "\n"), } - defer solver.CleanUp(ctx, acme.Challenge{}) + if err = solver.Present(ctx, fakeChallenge); err != nil { + return nil, fmt.Errorf("presenting validation file for verification: %v", err) + } + defer solver.CleanUp(ctx, fakeChallenge) } else { verificationMethod = zerossl.CNAMEVerification logger = logger.With(zap.String("verification_method", string(verificationMethod))) @@ -273,6 +261,32 @@ func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert CertificateResource, return iss.getClient().RevokeCertificate(ctx, cert.IssuerData.(zerossl.CertificateObject).ID, r) } +func (iss *ZeroSSLIssuer) getDistributedValidationInfo(ctx context.Context, identifier string) (acme.Challenge, bool, error) { + ds := distributedSolver{ + storage: iss.Storage, + storageKeyIssuerPrefix: StorageKeys.Safe(iss.IssuerKey()), + } + tokenKey := ds.challengeTokensKey(identifier) + + valObjectBytes, err := iss.Storage.Load(ctx, tokenKey) + if err != nil { + return acme.Challenge{}, false, fmt.Errorf("opening distributed challenge token file %s: %v", tokenKey, err) + } + + if len(valObjectBytes) == 0 { + return acme.Challenge{}, false, fmt.Errorf("no information found to solve challenge for identifier: %s", identifier) + } + + // since the distributed solver's API is geared around ACME challenges, + // we crammed the validation info into a Challenge object + var chal acme.Challenge + if err = json.Unmarshal(valObjectBytes, &chal); err != nil { + return acme.Challenge{}, false, fmt.Errorf("decoding HTTP validation token file %s (corrupted?): %v", tokenKey, err) + } + + return chal, true, nil +} + const ( zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme" zerosslValidationPathPrefix = "/.well-known/pki-validation/"