Implement distributed HTTP solver for ZeroSSL
This commit is contained in:
parent
aa4d957707
commit
167015dd65
@ -16,6 +16,7 @@ package certmagic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mholt/acmez/v2/acme"
|
"github.com/mholt/acmez/v2/acme"
|
||||||
@ -91,7 +92,7 @@ func solveHTTPChallenge(logger *zap.Logger, w http.ResponseWriter, r *http.Reque
|
|||||||
challengeReqPath := challenge.HTTP01ResourcePath()
|
challengeReqPath := challenge.HTTP01ResourcePath()
|
||||||
if r.URL.Path == challengeReqPath &&
|
if r.URL.Path == challengeReqPath &&
|
||||||
strings.EqualFold(hostOnly(r.Host), challenge.Identifier.Value) && // mitigate DNS rebinding attacks
|
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.Header().Add("Content-Type", "text/plain")
|
||||||
w.Write([]byte(challenge.KeyAuthorization))
|
w.Write([]byte(challenge.KeyAuthorization))
|
||||||
r.Close = true
|
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
|
// LooksLikeHTTPChallenge returns true if r looks like an ACME
|
||||||
// HTTP challenge request from an ACME server.
|
// HTTP challenge request from an ACME server.
|
||||||
func LooksLikeHTTPChallenge(r *http.Request) bool {
|
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/"
|
||||||
|
)
|
@ -17,10 +17,10 @@ package certmagic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -68,6 +68,9 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
|
|||||||
client := iss.getClient()
|
client := iss.getClient()
|
||||||
|
|
||||||
identifiers := namesFromCSR(csr)
|
identifiers := namesFromCSR(csr)
|
||||||
|
if len(identifiers) == 0 {
|
||||||
|
return nil, fmt.Errorf("no identifiers on CSR")
|
||||||
|
}
|
||||||
|
|
||||||
logger := iss.Logger
|
logger := iss.Logger
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
@ -104,35 +107,7 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
|
|||||||
|
|
||||||
httpVerifier := &httpSolver{
|
httpVerifier := &httpSolver{
|
||||||
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())),
|
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())),
|
||||||
handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
handler: iss.HTTPValidationHandler(http.NewServeMux()),
|
||||||
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"))
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var solver acmez.Solver = httpVerifier
|
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 {
|
// since the distributed solver was originally designed for ACME,
|
||||||
return nil, fmt.Errorf("presenting token for verification: %v", err)
|
// 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 {
|
} else {
|
||||||
verificationMethod = zerossl.CNAMEVerification
|
verificationMethod = zerossl.CNAMEVerification
|
||||||
logger = logger.With(zap.String("verification_method", string(verificationMethod)))
|
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)
|
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 (
|
const (
|
||||||
zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme"
|
zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme"
|
||||||
zerosslValidationPathPrefix = "/.well-known/pki-validation/"
|
zerosslValidationPathPrefix = "/.well-known/pki-validation/"
|
||||||
|
Loading…
Reference in New Issue
Block a user