Skip to content

Commit

Permalink
Implement distributed HTTP solver for ZeroSSL
Browse files Browse the repository at this point in the history
  • Loading branch information
mholt committed Apr 12, 2024
1 parent aa4d957 commit 167015d
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 36 deletions.
94 changes: 91 additions & 3 deletions httphandler.go → httphandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package certmagic

import (
"net/http"
"net/url"
"strings"

"github.com/mholt/acmez/v2/acme"
Expand Down Expand Up @@ -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
Expand All @@ -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/"
)
File renamed without changes.
80 changes: 47 additions & 33 deletions zerosslissuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ package certmagic
import (
"context"
"crypto/x509"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"),
}
if err = solver.Present(ctx, fakeChallenge); err != nil {
return nil, fmt.Errorf("presenting validation file for verification: %v", err)
}
defer solver.CleanUp(ctx, acme.Challenge{})
defer solver.CleanUp(ctx, fakeChallenge)
} else {
verificationMethod = zerossl.CNAMEVerification
logger = logger.With(zap.String("verification_method", string(verificationMethod)))
Expand Down Expand Up @@ -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/"
Expand Down

0 comments on commit 167015d

Please sign in to comment.