From a8586d05cdb59b95bf6b0e66d66cf5ff87cf12e4 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 6 Sep 2019 13:35:08 -0700 Subject: [PATCH] Add integration test for precertificate OCSP. (#4417) This test adds support in ct-test-srv for rejecting precertificates by hostname, in order to artificially generate a condition where a precertificate is issued but no final certificate can be issued. Right now the final check in the test is temporarily disabled until the feature is fixed. Also, as our first Go-based integration test, this pulls in the eggsampler/acme Go client, and adds some suport in integration-test.py. This also refactors ct-test-srv slightly to use a ServeMux, and fixes a couple of cases of not returning immediately on error. --- go.mod | 1 + go.sum | 2 + test/ct-test-srv/main.go | 196 +++++--- test/integration-test.py | 5 + test/integration/common_test.go | 156 +++++++ test/integration/ocsp_test.go | 71 +++ test/ocsp/helper/helper.go | 6 +- .../github.com/eggsampler/acme/v2/.gitignore | 3 + .../github.com/eggsampler/acme/v2/.travis.yml | 18 + vendor/github.com/eggsampler/acme/v2/LICENSE | 21 + vendor/github.com/eggsampler/acme/v2/Makefile | 59 +++ .../github.com/eggsampler/acme/v2/README.md | 25 + .../github.com/eggsampler/acme/v2/THIRD-PARTY | 35 ++ .../github.com/eggsampler/acme/v2/account.go | 124 +++++ vendor/github.com/eggsampler/acme/v2/acme.go | 256 +++++++++++ .../eggsampler/acme/v2/authorization.go | 43 ++ .../github.com/eggsampler/acme/v2/autocert.go | 430 ++++++++++++++++++ .../eggsampler/acme/v2/certificate.go | 67 +++ .../eggsampler/acme/v2/challenge.go | 102 +++++ vendor/github.com/eggsampler/acme/v2/go.mod | 3 + vendor/github.com/eggsampler/acme/v2/jws.go | 167 +++++++ vendor/github.com/eggsampler/acme/v2/nonce.go | 45 ++ .../github.com/eggsampler/acme/v2/options.go | 70 +++ vendor/github.com/eggsampler/acme/v2/order.go | 141 ++++++ .../github.com/eggsampler/acme/v2/problem.go | 61 +++ vendor/github.com/eggsampler/acme/v2/types.go | 155 +++++++ vendor/modules.txt | 2 + 27 files changed, 2200 insertions(+), 64 deletions(-) create mode 100644 test/integration/common_test.go create mode 100644 test/integration/ocsp_test.go create mode 100644 vendor/github.com/eggsampler/acme/v2/.gitignore create mode 100644 vendor/github.com/eggsampler/acme/v2/.travis.yml create mode 100644 vendor/github.com/eggsampler/acme/v2/LICENSE create mode 100644 vendor/github.com/eggsampler/acme/v2/Makefile create mode 100644 vendor/github.com/eggsampler/acme/v2/README.md create mode 100644 vendor/github.com/eggsampler/acme/v2/THIRD-PARTY create mode 100644 vendor/github.com/eggsampler/acme/v2/account.go create mode 100644 vendor/github.com/eggsampler/acme/v2/acme.go create mode 100644 vendor/github.com/eggsampler/acme/v2/authorization.go create mode 100644 vendor/github.com/eggsampler/acme/v2/autocert.go create mode 100644 vendor/github.com/eggsampler/acme/v2/certificate.go create mode 100644 vendor/github.com/eggsampler/acme/v2/challenge.go create mode 100644 vendor/github.com/eggsampler/acme/v2/go.mod create mode 100644 vendor/github.com/eggsampler/acme/v2/jws.go create mode 100644 vendor/github.com/eggsampler/acme/v2/nonce.go create mode 100644 vendor/github.com/eggsampler/acme/v2/options.go create mode 100644 vendor/github.com/eggsampler/acme/v2/order.go create mode 100644 vendor/github.com/eggsampler/acme/v2/problem.go create mode 100644 vendor/github.com/eggsampler/acme/v2/types.go diff --git a/go.mod b/go.mod index eba993736..fb80a6b6c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/beeker1121/goque v0.0.0-20170321141813-4044bc29b280 github.com/beorn7/perks v0.0.0-20160229213445-3ac7bf7a47d1 // indirect github.com/cloudflare/cfssl v0.0.0-20190716004220-2185c182e6ba + github.com/eggsampler/acme/v2 v2.0.1 github.com/go-gorp/gorp v2.0.0+incompatible // indirect github.com/go-sql-driver/mysql v0.0.0-20170715192408-3955978caca4 github.com/golang/mock v1.2.0 diff --git a/go.sum b/go.sum index c2719c13c..649ab59b8 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7 h1:Puu1hUwfps3+1C github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eggsampler/acme/v2 v2.0.1 h1:SfhaHP/6jCWOEMdzWI/9pmNDm2yuCHd4agW7u29fEJY= +github.com/eggsampler/acme/v2 v2.0.1/go.mod h1:kMR4S+ZCJtXb0WCg8MJkUKFQ0pyLEMQ9l5JA+CHvK2Y= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= diff --git a/test/ct-test-srv/main.go b/test/ct-test-srv/main.go index b6ecb3a68..819f24877 100644 --- a/test/ct-test-srv/main.go +++ b/test/ct-test-srv/main.go @@ -27,83 +27,146 @@ type ctSubmissionRequest struct { type integrationSrv struct { sync.Mutex - submissions map[string]int64 + submissions map[string]int64 + // Hostnames where we refuse to provide an SCT. This is to exercise the code + // path where all CT servers fail. + rejectHosts map[string]bool + // A list of entries that we rejected based on rejectHosts. + rejected []string key *ecdsa.PrivateKey latencySchedule []float64 latencyItem int } -func (is *integrationSrv) handler(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/ct/v1/add-pre-chain": - fallthrough - case "/ct/v1/add-chain": - if r.Method != "POST" { - http.NotFound(w, r) - return - } - bodyBytes, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } +func readJSON(w http.ResponseWriter, r *http.Request, output interface{}) error { + if r.Method != "POST" { + return fmt.Errorf("incorrect method; only POST allowed") + } + bodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } - var addChainReq ctSubmissionRequest - err = json.Unmarshal(bodyBytes, &addChainReq) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } - if len(addChainReq.Chain) == 0 { - w.WriteHeader(400) - return - } + err = json.Unmarshal(bodyBytes, output) + if err != nil { + return err + } + return nil +} - precert := false - if r.URL.Path == "/ct/v1/add-pre-chain" { - precert = true - } +func (is *integrationSrv) addChain(w http.ResponseWriter, r *http.Request) { + is.addChainOrPre(w, r, false) +} - b, err := base64.StdEncoding.DecodeString(addChainReq.Chain[0]) - if err != nil { - w.WriteHeader(400) - return - } - cert, err := x509.ParseCertificate(b) - if err != nil { - w.WriteHeader(400) - return - } - hostnames := strings.Join(cert.DNSNames, ",") +// addRejectHost takes a JSON POST with a "host" field; any subsequent +// submissions for that host will get a 400 error. +func (is *integrationSrv) addRejectHost(w http.ResponseWriter, r *http.Request) { + var rejectHostReq struct { + Host string + } + err := readJSON(w, r, &rejectHostReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - is.Lock() - is.submissions[hostnames]++ - is.Unlock() + is.Lock() + defer is.Unlock() + is.rejectHosts[rejectHostReq.Host] = true + w.Write([]byte{}) +} - if is.latencySchedule != nil { - is.Lock() - sleepTime := time.Duration(is.latencySchedule[is.latencyItem%len(is.latencySchedule)]) * time.Second - is.latencyItem++ - is.Unlock() - time.Sleep(sleepTime) - } - w.WriteHeader(http.StatusOK) - w.Write(publisher.CreateTestingSignedSCT(addChainReq.Chain, is.key, precert, time.Now())) - case "/submissions": - if r.Method != "GET" { - http.NotFound(w, r) - return - } +// getRejections returns a JSON array containing strings; those strings are +// base64 encodings of certificates or precertificates that were rejected due to +// the rejectHosts mechanism. +func (is *integrationSrv) getRejections(w http.ResponseWriter, r *http.Request) { + is.Lock() + defer is.Unlock() + output, err := json.Marshal(is.rejected) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - is.Lock() - hostnames := r.URL.Query().Get("hostnames") - submissions := is.submissions[hostnames] - is.Unlock() + w.WriteHeader(http.StatusOK) + w.Write(output) +} - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "%d", submissions) - default: +func (is *integrationSrv) addPreChain(w http.ResponseWriter, r *http.Request) { + is.addChainOrPre(w, r, true) +} + +func (is *integrationSrv) addChainOrPre(w http.ResponseWriter, r *http.Request, precert bool) { + if r.Method != "POST" { http.NotFound(w, r) return } + bodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var addChainReq ctSubmissionRequest + err = json.Unmarshal(bodyBytes, &addChainReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(addChainReq.Chain) == 0 { + w.WriteHeader(400) + return + } + + b, err := base64.StdEncoding.DecodeString(addChainReq.Chain[0]) + if err != nil { + w.WriteHeader(400) + return + } + cert, err := x509.ParseCertificate(b) + if err != nil { + w.WriteHeader(400) + return + } + hostnames := strings.Join(cert.DNSNames, ",") + + is.Lock() + for _, h := range cert.DNSNames { + if is.rejectHosts[h] { + is.Unlock() + is.rejected = append(is.rejected, addChainReq.Chain[0]) + w.WriteHeader(400) + return + } + } + + is.submissions[hostnames]++ + is.Unlock() + + if is.latencySchedule != nil { + is.Lock() + sleepTime := time.Duration(is.latencySchedule[is.latencyItem%len(is.latencySchedule)]) * time.Second + is.latencyItem++ + is.Unlock() + time.Sleep(sleepTime) + } + w.WriteHeader(http.StatusOK) + w.Write(publisher.CreateTestingSignedSCT(addChainReq.Chain, is.key, precert, time.Now())) +} + +func (is *integrationSrv) getSubmissions(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.NotFound(w, r) + return + } + + is.Lock() + hostnames := r.URL.Query().Get("hostnames") + submissions := is.submissions[hostnames] + is.Unlock() + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%d", submissions) } type config struct { @@ -139,10 +202,17 @@ func runPersonality(p Personality) { key: key, latencySchedule: p.LatencySchedule, submissions: make(map[string]int64), + rejectHosts: make(map[string]bool), } + m := http.NewServeMux() + m.HandleFunc("/submissions", is.getSubmissions) + m.HandleFunc("/ct/v1/add-pre-chain", is.addPreChain) + m.HandleFunc("/ct/v1/add-chain", is.addChain) + m.HandleFunc("/add-reject-host", is.addRejectHost) + m.HandleFunc("/get-rejections", is.getRejections) srv := &http.Server{ Addr: p.Addr, - Handler: http.HandlerFunc(is.handler), + Handler: m, } log.Printf("ct-test-srv on %s with pubkey %s", p.Addr, base64.StdEncoding.EncodeToString(pubKeyBytes)) diff --git a/test/integration-test.py b/test/integration-test.py index 590d11a8e..c77e8383d 100644 --- a/test/integration-test.py +++ b/test/integration-test.py @@ -38,6 +38,9 @@ def run_client_tests(): cmd = os.path.join(root, 'tests', 'boulder-integration.sh') run(cmd, cwd=root) +def run_go_tests(): + run("go test -tags integration -count=1 ./test/integration") + def run_expired_authz_purger(): # Note: This test must be run after all other tests that depend on # authorizations added to the database during setup @@ -256,6 +259,8 @@ def main(): if args.run_certbot: run_client_tests() + run_go_tests() + if args.custom: run(args.custom) diff --git a/test/integration/common_test.go b/test/integration/common_test.go new file mode 100644 index 000000000..0a7b73412 --- /dev/null +++ b/test/integration/common_test.go @@ -0,0 +1,156 @@ +// +build integration + +package integration + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "fmt" + "net/http" + "os" + + "github.com/eggsampler/acme/v2" +) + +func random_domain() string { + var bytes [3]byte + rand.Read(bytes[:]) + return hex.EncodeToString(bytes[:]) + ".com" +} + +type client struct { + acme.Account + acme.Client +} + +func makeClient() (*client, error) { + c, err := acme.NewClient(os.Getenv("DIRECTORY")) + if err != nil { + return nil, fmt.Errorf("Error connecting to acme directory: %v", err) + } + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("error creating private key: %v", err) + } + account, err := c.NewAccount(privKey, false, true, "mailto:example@letsencrypt.org") + if err != nil { + return nil, fmt.Errorf("error creating new account: %v", err) + } + return &client{account, c}, nil +} + +func addHTTP01Response(token, keyAuthorization string) error { + resp, err := http.Post("http://boulder:8055/add-http01", "", + bytes.NewBufferString(fmt.Sprintf(`{ + "token": "%s", + "content": "%s" + }`, token, keyAuthorization))) + if err != nil { + return fmt.Errorf("adding http-01 response: %s", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("adding http-01 response: status %d", resp.StatusCode) + } + resp.Body.Close() + return nil +} + +func delHTTP01Response(token string) error { + resp, err := http.Post("http://boulder:8055/del-http01", "", + bytes.NewBufferString(fmt.Sprintf(`{ + "token": "%s" + }`, token))) + if err != nil { + return fmt.Errorf("deleting http-01 response: %s", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("deleting http-01 response: status %d", resp.StatusCode) + } + resp.Body.Close() + return nil +} + +type issuanceResult struct { + acme.Order + certs []*x509.Certificate +} + +func authAndIssue(domains []string) (*issuanceResult, error) { + c, err := makeClient() + if err != nil { + return nil, err + } + var ids []acme.Identifier + for _, domain := range domains { + ids = append(ids, acme.Identifier{Type: "dns", Value: domain}) + } + order, err := c.Client.NewOrder(c.Account, ids) + if err != nil { + return nil, fmt.Errorf("making order: %s", err) + } + + for _, authUrl := range order.Authorizations { + auth, err := c.Client.FetchAuthorization(c.Account, authUrl) + if err != nil { + return nil, fmt.Errorf("fetching authorization at %s: %s", authUrl, err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeHTTP01] + if !ok { + return nil, fmt.Errorf("no HTTP challenge at %s", authUrl) + } + + err = addHTTP01Response(chal.Token, chal.KeyAuthorization) + if err != nil { + return nil, fmt.Errorf("adding HTTP-01 response: %s", err) + } + defer delHTTP01Response(chal.Token) + chal, err = c.Client.UpdateChallenge(c.Account, chal) + if err != nil { + return nil, fmt.Errorf("updating challenge: %s", err) + } + } + + csr, err := makeCSR(domains) + if err != nil { + return nil, err + } + + order, err = c.Client.FinalizeOrder(c.Account, order, csr) + if err != nil { + return nil, fmt.Errorf("finalizing order: %s", err) + } + certs, err := c.Client.FetchCertificates(c.Account, order.Certificate) + if err != nil { + return nil, fmt.Errorf("fetching certificates: %s", err) + } + return &issuanceResult{order, certs}, nil +} + +func makeCSR(domains []string) (*x509.CertificateRequest, error) { + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating certificate key: %s", err) + } + + csrDer, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + PublicKeyAlgorithm: x509.ECDSA, + PublicKey: certKey.Public(), + Subject: pkix.Name{CommonName: domains[0]}, + DNSNames: domains, + }, certKey) + if err != nil { + return nil, fmt.Errorf("making csr: %s", err) + } + csr, err := x509.ParseCertificateRequest(csrDer) + if err != nil { + return nil, fmt.Errorf("parsing csr: %s", err) + } + return csr, nil +} diff --git a/test/integration/ocsp_test.go b/test/integration/ocsp_test.go new file mode 100644 index 000000000..878fed305 --- /dev/null +++ b/test/integration/ocsp_test.go @@ -0,0 +1,71 @@ +// +build integration + +package integration + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + + ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +func TestPrecertificateOCSP(t *testing.T) { + domain := random_domain() + for _, port := range []int{4500, 4501, 4510, 4511} { + url := fmt.Sprintf("http://boulder:%d/add-reject-host", port) + body := []byte(fmt.Sprintf(`{"host": "%s"}`, domain)) + resp, err := http.Post(url, "", bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("adding reject host: %s", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("adding reject host: %d", resp.StatusCode) + } + resp.Body.Close() + } + + os.Setenv("DIRECTORY", "http://boulder:4001/directory") + _, err := authAndIssue([]string{domain}) + if err != nil { + if strings.Contains(err.Error(), "urn:ietf:params:acme:error:serverInternal") && + strings.Contains(err.Error(), "SCT embedding") { + } else { + t.Fatal(err) + } + } + if err == nil { + t.Fatal("expected error issuing for domain rejected by CT servers; got none") + } + + resp, err := http.Get("http://boulder:4500/get-rejections") + if err != nil { + t.Fatalf("getting rejections: %s", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("getting rejections: status %d", resp.StatusCode) + } + var rejections []string + err = json.NewDecoder(resp.Body).Decode(&rejections) + if err != nil { + t.Fatalf("parsing rejections: %s", err) + } + + for _, r := range rejections { + rejectedCertBytes, err := base64.StdEncoding.DecodeString(r) + if err != nil { + t.Fatalf("decoding rejected cert: %s", err) + } + _, err = ocsp_helper.ReqDER(rejectedCertBytes) + if err != nil { + // TODO(#4412): This should become a `t.Errorf` + t.Logf("requesting OCSP for rejected precertificate: %s", err) + } + } +} diff --git a/test/ocsp/helper/helper.go b/test/ocsp/helper/helper.go index f3c456c89..3c7e04cbf 100644 --- a/test/ocsp/helper/helper.go +++ b/test/ocsp/helper/helper.go @@ -96,7 +96,11 @@ func Req(fileName string) (*ocsp.Response, error) { if err != nil { return nil, err } - cert, err := parse(contents) + return ReqDER(contents) +} + +func ReqDER(der []byte) (*ocsp.Response, error) { + cert, err := parse(der) if err != nil { return nil, fmt.Errorf("parsing certificate: %s", err) } diff --git a/vendor/github.com/eggsampler/acme/v2/.gitignore b/vendor/github.com/eggsampler/acme/v2/.gitignore new file mode 100644 index 000000000..d2424fb42 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/.gitignore @@ -0,0 +1,3 @@ +.idea/ +*.out +coverage_*.txt \ No newline at end of file diff --git a/vendor/github.com/eggsampler/acme/v2/.travis.yml b/vendor/github.com/eggsampler/acme/v2/.travis.yml new file mode 100644 index 000000000..5511d35b2 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/.travis.yml @@ -0,0 +1,18 @@ +language: go + +go: +- "1.x" + +sudo: required + +services: + - docker + +script: + - unset TRAVIS_GO_VERSION + - make clean + - make pebble + - make boulder + +after_success: + - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/vendor/github.com/eggsampler/acme/v2/LICENSE b/vendor/github.com/eggsampler/acme/v2/LICENSE new file mode 100644 index 000000000..b9a4b3655 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Isaac + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/eggsampler/acme/v2/Makefile b/vendor/github.com/eggsampler/acme/v2/Makefile new file mode 100644 index 000000000..540f7ab6b --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/Makefile @@ -0,0 +1,59 @@ + +.PHONY: test pebble pebble_setup pebble_start pebble_wait pebble_stop boulder boulder_setup boulder_start boulder_stop + + +GOPATH ?= $(HOME)/go +BOULDER_PATH ?= $(GOPATH)/src/github.com/letsencrypt/boulder +PEBBLE_PATH ?= $(GOPATH)/src/github.com/letsencrypt/pebble +TEST_PATH ?= github.com/eggsampler/acme + + +# tests the code against a running ca instance +test: + $(eval COVERAGE = coverage_$(strip $(shell ls coverage* 2>/dev/null | wc -l)).txt) + -go clean -testcache + go test -race -coverprofile=$(COVERAGE) -covermode=atomic $(TEST_PATH) + +clean: + rm -f coverage_*.txt + + +pebble: pebble_setup pebble_start pebble_wait test pebble_stop + +pebble_setup: + mkdir -p $(PEBBLE_PATH) + git clone --depth 1 https://github.com/letsencrypt/pebble.git $(PEBBLE_PATH) \ + || (cd $(PEBBLE_PATH); git checkout -f master && git reset --hard HEAD && git pull -q) + +# runs an instance of pebble using docker +pebble_start: + docker-compose -f $(PEBBLE_PATH)/docker-compose.yml up -d + +# waits until pebble responds +pebble_wait: + while ! wget --delete-after -q --no-check-certificate "https://localhost:14000/dir" ; do sleep 1 ; done + +# stops the running pebble instance +pebble_stop: + docker-compose -f $(PEBBLE_PATH)/docker-compose.yml down + + +boulder: boulder_setup boulder_start boulder_wait test boulder_stop + +# NB: this edits docker-compose.yml +boulder_setup: + mkdir -p $(BOULDER_PATH) + git clone --depth 1 https://github.com/letsencrypt/boulder.git $(BOULDER_PATH) \ + || (cd $(BOULDER_PATH); git checkout -f master && git reset --hard HEAD && git pull -q) + +# runs an instance of boulder +boulder_start: + docker-compose -f $(BOULDER_PATH)/docker-compose.yml up -d + +# waits until boulder responds +boulder_wait: + while ! wget --delete-after -q --no-check-certificate "http://localhost:4001/directory" ; do sleep 1 ; done + +# stops the running docker instance +boulder_stop: + docker-compose -f $(BOULDER_PATH)/docker-compose.yml down diff --git a/vendor/github.com/eggsampler/acme/v2/README.md b/vendor/github.com/eggsampler/acme/v2/README.md new file mode 100644 index 000000000..a7637c232 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/README.md @@ -0,0 +1,25 @@ +# eggsampler/acme + +[![GoDoc](https://godoc.org/github.com/eggsampler/acme?status.svg)](https://godoc.org/github.com/eggsampler/acme) [![Build Status](https://travis-ci.com/eggsampler/acme.svg?branch=master)](https://travis-ci.com/eggsampler/acme) [![codecov.io](https://codecov.io/gh/eggsampler/acme/branch/master/graph/badge.svg)](https://codecov.io/gh/eggsampler/acme/branch/master) + +## About + +`eggsampler/acme` is a Go client library implementation for [RFC8555](https://tools.ietf.org/html/rfc8555) (previously ACME), specifically for use with the [Let's Encrypt](https://letsencrypt.org/) service. + +The library is designed to provide a zero external dependency wrapper over exposed directory endpoints and provide objects in easy to use structures. + +## Example + +A simple [certbot](https://certbot.eff.org/)-like example is provided in the examples/certbot directory. This code demonstrates account registration, new order submission, fulfilling challenges, finalising an order and fetching the issued certificate chain. + +An example of how to use the autocert package is also provided in examples/autocert. + +## Tests + +The tests can be run against an instance of [boulder](https://github.com/letsencrypt/boulder) or [pebble](https://github.com/letsencrypt/pebble). + +Challenge fulfilment is designed to use the new `challtestsrv` server present inside boulder and pebble which responds to dns queries and challenges as required. + +To run tests against an already running instance of boulder or pebble, use the `test` target in the Makefile. + +Some convenience targets for launching pebble/boulder using their respective docker compose files have also been included in the Makefile. diff --git a/vendor/github.com/eggsampler/acme/v2/THIRD-PARTY b/vendor/github.com/eggsampler/acme/v2/THIRD-PARTY new file mode 100644 index 000000000..1c53d7544 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/THIRD-PARTY @@ -0,0 +1,35 @@ +This document contains Third Party Software Notices and/or Additional Terms and Conditions for licensed third party software components included within this product. + +== + +https://github.com/golang/crypto/blob/master/acme/jws.go +https://github.com/golang/crypto/blob/master/acme/jws_test.go +(with modifications) + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/vendor/github.com/eggsampler/acme/v2/account.go b/vendor/github.com/eggsampler/acme/v2/account.go new file mode 100644 index 000000000..7c1ef949a --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/account.go @@ -0,0 +1,124 @@ +package acme + +import ( + "net/http" + + "fmt" + + "crypto" + "encoding/json" +) + +// NewAccount registers a new account with the acme service +func (c Client) NewAccount(privateKey crypto.Signer, onlyReturnExisting, termsOfServiceAgreed bool, contact ...string) (Account, error) { + if contact == nil { + // workaround for json marshalling {"contact":null} + contact = []string{} + // should now be {"contact":[]} + } + + newAccountReq := struct { + OnlyReturnExisting bool `json:"onlyReturnExisting"` + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact"` + }{ + OnlyReturnExisting: onlyReturnExisting, + TermsOfServiceAgreed: termsOfServiceAgreed, + Contact: contact, + } + + account := Account{} + resp, err := c.post(c.dir.NewAccount, "", privateKey, newAccountReq, &account, http.StatusOK, http.StatusCreated) + if err != nil { + return account, err + } + + account.URL = resp.Header.Get("Location") + account.PrivateKey = privateKey + + if account.Thumbprint == "" { + account.Thumbprint, err = JWKThumbprint(account.PrivateKey.Public()) + if err != nil { + return account, fmt.Errorf("acme: error computing account thumbprint: %v", err) + } + } + + return account, nil +} + +// UpdateAccount updates an existing account with the acme service. +func (c Client) UpdateAccount(account Account, termsOfServiceAgreed bool, contact ...string) (Account, error) { + if contact == nil { + // workaround for json marshalling {"contact":null} + contact = []string{} + // should now be {"contact":[]} + } + + updateAccountReq := struct { + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact"` + }{ + TermsOfServiceAgreed: termsOfServiceAgreed, + Contact: contact, + } + + _, err := c.post(account.URL, account.URL, account.PrivateKey, updateAccountReq, &account, http.StatusOK) + if err != nil { + return account, err + } + + if account.Thumbprint == "" { + account.Thumbprint, err = JWKThumbprint(account.PrivateKey.Public()) + if err != nil { + return account, fmt.Errorf("acme: error computing account thumbprint: %v", err) + } + } + + return account, nil +} + +// AccountKeyChange rolls over an account to a new key. +func (c Client) AccountKeyChange(account Account, newPrivateKey crypto.Signer) (Account, error) { + if c.dir.KeyChange == "" { + return account, ErrUnsupported + } + + oldJwkKeyPub, err := jwkEncode(account.PrivateKey.Public()) + if err != nil { + return account, fmt.Errorf("acme: error encoding new private key: %v", err) + } + + keyChangeReq := struct { + Account string `json:"account"` + OldKey json.RawMessage `json:"oldKey"` + }{ + Account: account.URL, + OldKey: []byte(oldJwkKeyPub), + } + + innerJws, err := jwsEncodeJSON(keyChangeReq, newPrivateKey, c.dir.KeyChange, "", "") + if err != nil { + return account, fmt.Errorf("acme: error encoding inner jws: %v", err) + } + + if _, err := c.post(c.dir.KeyChange, account.URL, account.PrivateKey, json.RawMessage(innerJws), nil, http.StatusOK); err != nil { + return account, err + } + + account.PrivateKey = newPrivateKey + + return account, nil +} + +// DeactivateAccount deactivates a given account. +func (c Client) DeactivateAccount(account Account) (Account, error) { + deactivateReq := struct { + Status string `json:"status"` + }{ + Status: "deactivated", + } + + _, err := c.post(account.URL, account.URL, account.PrivateKey, deactivateReq, &account, http.StatusOK) + + return account, err +} diff --git a/vendor/github.com/eggsampler/acme/v2/acme.go b/vendor/github.com/eggsampler/acme/v2/acme.go new file mode 100644 index 000000000..fb6a23150 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/acme.go @@ -0,0 +1,256 @@ +package acme + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "regexp" + + "strings" + + "bytes" + "crypto" + "errors" +) + +const ( + // LetsEncryptProduction holds the production directory url + LetsEncryptProduction = "https://acme-v02.api.letsencrypt.org/directory" + + // LetsEncryptStaging holds the staging directory url + LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory" + + userAgentString = "eggsampler-acme/1.0 Go-http-client/1.1" +) + +// NewClient creates a new acme client given a valid directory url. +func NewClient(directoryURL string, options ...OptionFunc) (Client, error) { + httpClient := http.DefaultClient + + // Set a default http timeout of 60 seconds + // can be overridden via OptionFunc eg: acme.NewClient(url, WithHTTPTimeout(10 * time.Second)) + httpClient.Timeout = 60 * time.Second + + acmeClient := Client{ + httpClient: httpClient, + nonces: &nonceStack{}, + retryCount: 5, + } + + acmeClient.dir.URL = directoryURL + + for _, opt := range options { + if err := opt(&acmeClient); err != nil { + return acmeClient, fmt.Errorf("acme: error setting option: %v", err) + } + } + + if _, err := acmeClient.get(directoryURL, &acmeClient.dir, http.StatusOK); err != nil { + return acmeClient, err + } + + return acmeClient, nil +} + +// The directory object returned by the client connecting to a directory url. +func (c Client) Directory() Directory { + return c.dir +} + +// Helper function to get the poll interval and poll timeout, defaulting if 0 +func (c Client) getPollingDurations() (time.Duration, time.Duration) { + pollInterval := c.PollInterval + if pollInterval == 0 { + pollInterval = 500 * time.Millisecond + } + pollTimeout := c.PollTimeout + if pollTimeout == 0 { + pollTimeout = 30 * time.Second + } + return pollInterval, pollTimeout +} + +// Helper function to have a central point for performing http requests. +// Stores any returned nonces in the stack. +func (c Client) do(req *http.Request, addNonce bool) (*http.Response, error) { + // identifier for this client, as well as the default go user agent + if c.userAgentSuffix != "" { + req.Header.Set("User-Agent", userAgentString+" "+c.userAgentSuffix) + } else { + req.Header.Set("User-Agent", userAgentString) + } + + if c.acceptLanguage != "" { + req.Header.Set("Accept-Language", c.acceptLanguage) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return resp, err + } + + if addNonce { + c.nonces.push(resp.Header.Get("Replay-Nonce")) + } + + return resp, nil +} + +// Helper function to perform an http get request and read the body. +func (c Client) getRaw(url string, expectedStatus ...int) (*http.Response, []byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, nil, fmt.Errorf("acme: error creating request: %v", err) + } + + resp, err := c.do(req, true) + if err != nil { + return resp, nil, fmt.Errorf("acme: error fetching response: %v", err) + } + defer resp.Body.Close() + + if err := checkError(resp, expectedStatus...); err != nil { + return resp, nil, err + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return resp, body, fmt.Errorf("acme: error reading response body: %v", err) + } + + return resp, body, nil +} + +// Helper function for performing a http get on an acme resource. +func (c Client) get(url string, out interface{}, expectedStatus ...int) (*http.Response, error) { + resp, body, err := c.getRaw(url, expectedStatus...) + if err != nil { + return resp, err + } + + if len(body) > 0 && out != nil { + if err := json.Unmarshal(body, out); err != nil { + return resp, fmt.Errorf("acme: error parsing response body: %v", err) + } + } + + return resp, nil +} + +func (c Client) nonce() (string, error) { + nonce := c.nonces.pop() + if nonce != "" { + return nonce, nil + } + + if c.dir.NewNonce == "" { + return "", errors.New("acme: no new nonce url") + } + + req, err := http.NewRequest("HEAD", c.dir.NewNonce, nil) + if err != nil { + return "", fmt.Errorf("acme: error creating new nonce request: %v", err) + } + + resp, err := c.do(req, false) + if err != nil { + return "", fmt.Errorf("acme: error fetching new nonce: %v", err) + } + + nonce = resp.Header.Get("Replay-Nonce") + return nonce, nil +} + +// Helper function to perform an http post request and read the body. +// Will attempt to retry if error is badNonce +func (c Client) postRaw(retryCount int, requestURL, keyID string, privateKey crypto.Signer, payload interface{}, expectedStatus []int) (*http.Response, []byte, error) { + nonce, err := c.nonce() + if err != nil { + return nil, nil, err + } + + data, err := jwsEncodeJSON(payload, privateKey, requestURL, keyID, nonce) + if err != nil { + return nil, nil, fmt.Errorf("acme: error encoding json payload: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(data)) + if err != nil { + return nil, nil, fmt.Errorf("acme: error creating request: %v", err) + } + req.Header.Set("Content-Type", "application/jose+json") + + resp, err := c.do(req, true) + if err != nil { + return resp, nil, fmt.Errorf("acme: error sending request: %v", err) + } + defer resp.Body.Close() + + if err := checkError(resp, expectedStatus...); err != nil { + prob, ok := err.(Problem) + if !ok { + // don't retry for an error we don't know about + return resp, nil, err + } + if retryCount >= c.retryCount { + // don't attempt to retry if too many retries + return resp, nil, err + } + if strings.HasSuffix(prob.Type, ":badNonce") { + // only retry if error is badNonce + return c.postRaw(retryCount+1, requestURL, keyID, privateKey, payload, expectedStatus) + } + return resp, nil, err + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return resp, body, fmt.Errorf("acme: error reading response body: %v", err) + } + + return resp, body, nil +} + +// Helper function for performing a http post to an acme resource. +func (c Client) post(requestURL, keyID string, privateKey crypto.Signer, payload interface{}, out interface{}, expectedStatus ...int) (*http.Response, error) { + resp, body, err := c.postRaw(0, requestURL, keyID, privateKey, payload, expectedStatus) + if err != nil { + return resp, err + } + + if len(body) > 0 && out != nil { + if err := json.Unmarshal(body, out); err != nil { + return resp, fmt.Errorf("acme: error parsing response: %v - %s", err, string(body)) + } + } + + return resp, nil +} + +var regLink = regexp.MustCompile(`<(.+?)>;\s*rel="(.+?)"`) + +// Fetches a http Link header from a http response +func fetchLink(resp *http.Response, wantedLink string) string { + if resp == nil { + return "" + } + linkHeader := resp.Header["Link"] + if len(linkHeader) == 0 { + return "" + } + for _, l := range linkHeader { + matches := regLink.FindAllStringSubmatch(l, -1) + for _, m := range matches { + if len(m) != 3 { + continue + } + if m[2] == wantedLink { + return m[1] + } + } + } + return "" +} diff --git a/vendor/github.com/eggsampler/acme/v2/authorization.go b/vendor/github.com/eggsampler/acme/v2/authorization.go new file mode 100644 index 000000000..09d5906d9 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/authorization.go @@ -0,0 +1,43 @@ +package acme + +import "net/http" + +// FetchAuthorization fetches an authorization from an authorization url provided in an order. +func (c Client) FetchAuthorization(account Account, authURL string) (Authorization, error) { + authResp := Authorization{} + _, err := c.post(authURL, account.URL, account.PrivateKey, "", &authResp, http.StatusOK) + if err != nil { + return authResp, err + } + + for i := 0; i < len(authResp.Challenges); i++ { + if authResp.Challenges[i].KeyAuthorization == "" { + authResp.Challenges[i].KeyAuthorization = authResp.Challenges[i].Token + "." + account.Thumbprint + } + } + + authResp.ChallengeMap = map[string]Challenge{} + authResp.ChallengeTypes = []string{} + for _, c := range authResp.Challenges { + authResp.ChallengeMap[c.Type] = c + authResp.ChallengeTypes = append(authResp.ChallengeTypes, c.Type) + } + + authResp.URL = authURL + + return authResp, nil +} + +// DeactivateAuthorization deactivate a provided authorization url from an order. +func (c Client) DeactivateAuthorization(account Account, authURL string) (Authorization, error) { + deactivateReq := struct { + Status string `json:"status"` + }{ + Status: "deactivated", + } + deactivateResp := Authorization{} + + _, err := c.post(authURL, account.URL, account.PrivateKey, deactivateReq, &deactivateResp, http.StatusOK) + + return deactivateResp, err +} diff --git a/vendor/github.com/eggsampler/acme/v2/autocert.go b/vendor/github.com/eggsampler/acme/v2/autocert.go new file mode 100644 index 000000000..9b03a25c1 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/autocert.go @@ -0,0 +1,430 @@ +package acme + +// Similar to golang.org/x/crypto/acme/autocert + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "net/http" + "path" + "strings" + "sync" +) + +// HostCheck function prototype to implement for checking hosts against before issuing certificates +type HostCheck func(host string) error + +// WhitelistHosts implements a simple whitelist HostCheck +func WhitelistHosts(hosts ...string) HostCheck { + m := map[string]bool{} + for _, v := range hosts { + m[v] = true + } + + return func(host string) error { + if !m[host] { + return errors.New("autocert: host not whitelisted") + } + return nil + } +} + +// AutoCert is a stateful certificate manager for issuing certificates on connecting hosts +type AutoCert struct { + // Acme directory Url + // If nil, uses `LetsEncryptStaging` + DirectoryURL string + + // Options contains the options used for creating the acme client + Options []OptionFunc + + // A function to check whether a host is allowed or not + // If nil, all hosts allowed + // Use `WhitelistHosts(hosts ...string)` for a simple white list of hostnames + HostCheck HostCheck + + // Cache dir to store account data and certificates + // If nil, does not write cache data to file + CacheDir string + + // When using a staging environment, include a root certificate for verification purposes + RootCert string + + // Called before updating challenges + PreUpdateChallengeHook func(Account, Challenge) + + // Mapping of token -> keyauth + // Protected by a mutex, but not rwmutex because tokens are deleted once read + tokensLock sync.RWMutex + tokens map[string][]byte + + // Mapping of cache key -> value + cacheLock sync.Mutex + cache map[string][]byte + + // read lock around getting existing certs + // write lock around issuing new certificate + certLock sync.RWMutex + + client Client +} + +// HTTPHandler Wraps a handler and provides serving of http-01 challenge tokens from /.well-known/acme-challenge/ +// If handler is nil, will redirect all traffic otherwise to https +func (m *AutoCert) HTTPHandler(handler http.Handler) http.Handler { + if handler == nil { + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusMovedPermanently) + }) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") { + handler.ServeHTTP(w, r) + return + } + + if err := m.checkHost(r.Host); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + token := path.Base(r.URL.Path) + m.tokensLock.RLock() + defer m.tokensLock.RUnlock() + keyAuth := m.tokens[token] + if len(keyAuth) == 0 { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + w.Write(keyAuth) + }) +} + +// GetCertificate implements a tls.Config.GetCertificate hook +func (m *AutoCert) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := strings.TrimSuffix(hello.ServerName, ".") + + if name == "" { + return nil, errors.New("autocert: missing server name") + } + if !strings.Contains(strings.Trim(name, "."), ".") { + return nil, errors.New("autocert: server name component count invalid") + } + if strings.ContainsAny(name, `/\`) { + return nil, errors.New("autocert: server name contains invalid character") + } + + // check the hostname is allowed + if err := m.checkHost(name); err != nil { + return nil, err + } + + // check if there's an existing cert + m.certLock.RLock() + existingCert := m.getExistingCert(name) + m.certLock.RUnlock() + if existingCert != nil { + return existingCert, nil + } + + // if not, attempt to issue a new cert + m.certLock.Lock() + defer m.certLock.Unlock() + return m.issueCert(name) +} + +func (m *AutoCert) getDirectoryURL() string { + if m.DirectoryURL != "" { + return m.DirectoryURL + } + + return LetsEncryptStaging +} + +func (m *AutoCert) getCache(keys ...string) []byte { + key := strings.Join(keys, "-") + + m.cacheLock.Lock() + defer m.cacheLock.Unlock() + + b := m.cache[key] + if len(b) > 0 { + return b + } + + if m.CacheDir == "" { + return nil + } + + b, _ = ioutil.ReadFile(path.Join(m.CacheDir, key)) + if len(b) == 0 { + return nil + } + + if m.cache == nil { + m.cache = map[string][]byte{} + } + m.cache[key] = b + return b +} + +func (m *AutoCert) putCache(data []byte, keys ...string) context.Context { + ctx, cancel := context.WithCancel(context.Background()) + + key := strings.Join(keys, "-") + + m.cacheLock.Lock() + defer m.cacheLock.Unlock() + + if m.cache == nil { + m.cache = map[string][]byte{} + } + m.cache[key] = data + + if m.CacheDir == "" { + cancel() + return ctx + } + + go func() { + ioutil.WriteFile(path.Join(m.CacheDir, key), data, 0700) + cancel() + }() + + return ctx +} + +func (m *AutoCert) checkHost(name string) error { + if m.HostCheck == nil { + return nil + } + return m.HostCheck(name) +} + +func (m *AutoCert) getExistingCert(name string) *tls.Certificate { + // check for a stored cert + certData := m.getCache("cert", name) + if len(certData) == 0 { + // no cert + return nil + } + + privBlock, pubData := pem.Decode(certData) + if len(pubData) == 0 { + // no public key data (cert/issuer), ignore + return nil + } + + // decode pub chain + var pubDER [][]byte + var pub []byte + for len(pubData) > 0 { + var b *pem.Block + b, pubData = pem.Decode(pubData) + if b == nil { + break + } + pubDER = append(pubDER, b.Bytes) + pub = append(pub, b.Bytes...) + } + if len(pubData) > 0 { + // leftover data in file - possibly corrupt, ignore + return nil + } + + certs, err := x509.ParseCertificates(pub) + if err != nil { + // bad certificates, ignore + return nil + } + + leaf := certs[0] + + // add any intermediate certs if present + var intermediates *x509.CertPool + if len(certs) > 1 { + intermediates = x509.NewCertPool() + for i := 1; i < len(certs); i++ { + intermediates.AddCert(certs[i]) + } + } + + // add a root certificate if present + var roots *x509.CertPool + if m.RootCert != "" { + roots = x509.NewCertPool() + rootBlock, _ := pem.Decode([]byte(m.RootCert)) + rootCert, err := x509.ParseCertificate(rootBlock.Bytes) + if err != nil { + return nil + } + roots.AddCert(rootCert) + } + + if _, err := leaf.Verify(x509.VerifyOptions{DNSName: name, Intermediates: intermediates, Roots: roots}); err != nil { + // invalid certificates , ignore + return nil + } + + privKey, err := x509.ParseECPrivateKey(privBlock.Bytes) + if err != nil { + // invalid private key, ignore + return nil + } + + return &tls.Certificate{ + Certificate: pubDER, + PrivateKey: privKey, + Leaf: leaf, + } +} + +func (m *AutoCert) issueCert(domainName string) (*tls.Certificate, error) { + // attempt to load an existing account key + var privKey *ecdsa.PrivateKey + if keyData := m.getCache("account"); len(keyData) > 0 { + block, _ := pem.Decode(keyData) + x509Encoded := block.Bytes + privKey, _ = x509.ParseECPrivateKey(x509Encoded) + } + + // otherwise generate a new one + if privKey == nil { + var err error + privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("autocert: error generating new account key: %v", err) + } + + x509Encoded, _ := x509.MarshalECPrivateKey(privKey) + pemEncoded := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: x509Encoded}) + + m.putCache(pemEncoded, "account") + } + + // create a new client if one doesn't exist + if m.client.Directory().URL == "" { + var err error + m.client, err = NewClient(m.getDirectoryURL(), m.Options...) + if err != nil { + return nil, err + } + } + + // create/fetch acme account + account, err := m.client.NewAccount(privKey, false, true) + if err != nil { + return nil, fmt.Errorf("autocert: error creating/fetching account: %v", err) + } + + // start a new order process + order, err := m.client.NewOrderDomains(account, domainName) + if err != nil { + return nil, fmt.Errorf("autocert: error creating new order for domain %s: %v", domainName, err) + } + + // loop through each of the provided authorization Urls + for _, authURL := range order.Authorizations { + auth, err := m.client.FetchAuthorization(account, authURL) + if err != nil { + return nil, fmt.Errorf("autocert: error fetching authorization Url %q: %v", authURL, err) + } + + if auth.Status == "valid" { + continue + } + + chal, ok := auth.ChallengeMap[ChallengeTypeHTTP01] + if !ok { + return nil, fmt.Errorf("autocert: unable to find http-01 challenge for auth %s, Url: %s", auth.Identifier.Value, authURL) + } + + m.tokensLock.Lock() + if m.tokens == nil { + m.tokens = map[string][]byte{} + } + m.tokens[chal.Token] = []byte(chal.KeyAuthorization) + m.tokensLock.Unlock() + + if m.PreUpdateChallengeHook != nil { + m.PreUpdateChallengeHook(account, chal) + } + + chal, err = m.client.UpdateChallenge(account, chal) + if err != nil { + return nil, fmt.Errorf("autocert: error updating authorization %s challenge (Url: %s) : %v", auth.Identifier.Value, authURL, err) + } + + m.tokensLock.Lock() + delete(m.tokens, chal.Token) + m.tokensLock.Unlock() + } + + // generate private key for cert + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("autocert: error generating certificate key for %s: %v", domainName, err) + } + certKeyEnc, err := x509.MarshalECPrivateKey(certKey) + if err != nil { + return nil, fmt.Errorf("autocert: error encoding certificate key for %s: %v", domainName, err) + } + certKeyPem := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: certKeyEnc, + }) + + // create the new csr template + tpl := &x509.CertificateRequest{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + PublicKeyAlgorithm: x509.ECDSA, + PublicKey: certKey.Public(), + Subject: pkix.Name{CommonName: domainName}, + DNSNames: []string{domainName}, + } + csrDer, err := x509.CreateCertificateRequest(rand.Reader, tpl, certKey) + if err != nil { + return nil, fmt.Errorf("autocert: error creating certificate request for %s: %v", domainName, err) + } + csr, err := x509.ParseCertificateRequest(csrDer) + if err != nil { + return nil, fmt.Errorf("autocert: error parsing certificate request for %s: %v", domainName, err) + } + + // finalize the order with the acme server given a csr + order, err = m.client.FinalizeOrder(account, order, csr) + if err != nil { + return nil, fmt.Errorf("autocert: error finalizing order for %s: %v", domainName, err) + } + + // fetch the certificate chain from the finalized order provided by the acme server + certs, err := m.client.FetchCertificates(account, order.Certificate) + if err != nil { + return nil, fmt.Errorf("autocert: error fetching order certificates for %s: %v", domainName, err) + } + + certPem := certKeyPem + var certDer [][]byte + for _, c := range certs { + b := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: c.Raw, + }) + certPem = append(certPem, b...) + certDer = append(certDer, c.Raw) + } + m.putCache(certPem, "cert", domainName) + + return m.getExistingCert(domainName), nil +} diff --git a/vendor/github.com/eggsampler/acme/v2/certificate.go b/vendor/github.com/eggsampler/acme/v2/certificate.go new file mode 100644 index 000000000..7d100f496 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/certificate.go @@ -0,0 +1,67 @@ +package acme + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net/http" +) + +// FetchCertificates downloads a certificate chain from a url given in an order certificate. +func (c Client) FetchCertificates(account Account, certificateURL string) ([]*x509.Certificate, error) { + resp, body, err := c.postRaw(0, certificateURL, account.URL, account.PrivateKey, "", []int{http.StatusOK}) + if err != nil { + return nil, err + } + + var certs []*x509.Certificate + for { + var p *pem.Block + p, body = pem.Decode(body) + if p == nil { + break + } + cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return certs, fmt.Errorf("acme: error parsing certificate: %v", err) + } + certs = append(certs, cert) + } + + up := fetchLink(resp, "up") + if up != "" { + upCerts, err := c.FetchCertificates(account, up) + if err != nil { + return certs, fmt.Errorf("acme: error fetching up cert: %v", err) + } + if len(upCerts) != 0 { + certs = append(certs, upCerts...) + } + } + + return certs, nil +} + +// RevokeCertificate revokes a given certificate given the certificate key or account key, and a reason. +func (c Client) RevokeCertificate(account Account, cert *x509.Certificate, key crypto.Signer, reason int) error { + revokeReq := struct { + Certificate string `json:"certificate"` + Reason int `json:"reason"` + }{ + Certificate: base64.RawURLEncoding.EncodeToString(cert.Raw), + Reason: reason, + } + + kid := "" + if key == account.PrivateKey { + kid = account.URL + } + + if _, err := c.post(c.dir.RevokeCert, kid, key, revokeReq, nil, http.StatusOK); err != nil { + return err + } + + return nil +} diff --git a/vendor/github.com/eggsampler/acme/v2/challenge.go b/vendor/github.com/eggsampler/acme/v2/challenge.go new file mode 100644 index 000000000..6d57bfb96 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/challenge.go @@ -0,0 +1,102 @@ +package acme + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "time" +) + +// EncodeDNS01KeyAuthorization encodes a key authorization and provides a value to be put in the TXT record for the _acme-challenge DNS entry. +func EncodeDNS01KeyAuthorization(keyAuth string) string { + h := sha256.Sum256([]byte(keyAuth)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// Helper function to determine whether a challenge is "finished" by it's status. +func checkUpdatedChallengeStatus(challenge Challenge) (bool, error) { + switch challenge.Status { + case "pending": + // Challenge objects are created in the "pending" state. + // TODO: https://github.com/letsencrypt/boulder/issues/3346 + // return true, errors.New("acme: unexpected 'pending' challenge state") + return false, nil + + case "processing": + // They transition to the "processing" state when the client responds to the + // challenge and the server begins attempting to validate that the client has completed the challenge. + return false, nil + + case "valid": + // If validation is successful, the challenge moves to the "valid" state + return true, nil + + case "invalid": + // if there is an error, the challenge moves to the "invalid" state. + if challenge.Error.Type != "" { + return true, challenge.Error + } + return true, errors.New("acme: challenge is invalid, no error provided") + + default: + return true, fmt.Errorf("acme: unknown challenge status: %s", challenge.Status) + } +} + +// UpdateChallenge responds to a challenge to indicate to the server to complete the challenge. +func (c Client) UpdateChallenge(account Account, challenge Challenge) (Challenge, error) { + resp, err := c.post(challenge.URL, account.URL, account.PrivateKey, struct{}{}, &challenge, http.StatusOK) + if err != nil { + return challenge, err + } + + if loc := resp.Header.Get("Location"); loc != "" { + challenge.URL = loc + } + challenge.AuthorizationURL = fetchLink(resp, "up") + + if finished, err := checkUpdatedChallengeStatus(challenge); finished { + return challenge, err + } + + pollInterval, pollTimeout := c.getPollingDurations() + end := time.Now().Add(pollTimeout) + for { + if time.Now().After(end) { + return challenge, errors.New("acme: challenge update timeout") + } + time.Sleep(pollInterval) + + resp, err := c.post(challenge.URL, account.URL, account.PrivateKey, "", &challenge, http.StatusOK) + if err != nil { + // i don't think it's worth exiting the loop on this error + // it could just be connectivity issue that's resolved before the timeout duration + continue + } + + if loc := resp.Header.Get("Location"); loc != "" { + challenge.URL = loc + } + challenge.AuthorizationURL = fetchLink(resp, "up") + + if finished, err := checkUpdatedChallengeStatus(challenge); finished { + return challenge, err + } + } +} + +// FetchChallenge fetches an existing challenge from the given url. +func (c Client) FetchChallenge(account Account, challengeURL string) (Challenge, error) { + challenge := Challenge{} + resp, err := c.post(challengeURL, account.URL, account.PrivateKey, "", &challenge, http.StatusOK) + if err != nil { + return challenge, err + } + + challenge.URL = resp.Header.Get("Location") + challenge.AuthorizationURL = fetchLink(resp, "up") + + return challenge, nil +} diff --git a/vendor/github.com/eggsampler/acme/v2/go.mod b/vendor/github.com/eggsampler/acme/v2/go.mod new file mode 100644 index 000000000..171ae96e9 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/go.mod @@ -0,0 +1,3 @@ +module github.com/eggsampler/acme/v2 + +go 1.12 diff --git a/vendor/github.com/eggsampler/acme/v2/jws.go b/vendor/github.com/eggsampler/acme/v2/jws.go new file mode 100644 index 000000000..7f87aae97 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/jws.go @@ -0,0 +1,167 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the THIRD-PARTY file. + +package acme + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + _ "crypto/sha512" // need for EC keys + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" +) + +var errUnsupportedKey = errors.New("acme: unknown key type; only RSA and ECDSA are supported") + +// jwsEncodeJSON signs claimset using provided key and a nonce. +// The result is serialized in JSON format. +// See https://tools.ietf.org/html/rfc7515#section-7. +func jwsEncodeJSON(claimset interface{}, key crypto.Signer, requestURL, keyID, nonce string) ([]byte, error) { + jwk, err := jwkEncode(key.Public()) + if err != nil { + return nil, err + } + alg, sha := jwsHasher(key) + if alg == "" || !sha.Available() { + return nil, errUnsupportedKey + } + var phead string + if keyID != "" { + phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, keyID, nonce, requestURL) + } else { + phead = fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q,"url":%q}`, alg, jwk, nonce, requestURL) + } + phead = base64.RawURLEncoding.EncodeToString([]byte(phead)) + + var payload string + csString, ok := claimset.(string) + if !ok || csString != "" { + cs, err := json.Marshal(claimset) + if err != nil { + return nil, err + } + payload = base64.RawURLEncoding.EncodeToString(cs) + } + + hash := sha.New() + hash.Write([]byte(phead + "." + payload)) + sig, err := jwsSign(key, sha, hash.Sum(nil)) + if err != nil { + return nil, err + } + + enc := struct { + Protected string `json:"protected"` + Payload string `json:"payload"` + Sig string `json:"signature"` + }{ + Protected: phead, + Payload: payload, + Sig: base64.RawURLEncoding.EncodeToString(sig), + } + return json.Marshal(&enc) +} + +// jwkEncode encodes public part of an RSA or ECDSA key into a JWK. +// The result is also suitable for creating a JWK thumbprint. +// https://tools.ietf.org/html/rfc7517 +func jwkEncode(pub crypto.PublicKey) (string, error) { + switch pub := pub.(type) { + case *rsa.PublicKey: + // https://tools.ietf.org/html/rfc7518#section-6.3.1 + n := pub.N + e := big.NewInt(int64(pub.E)) + // Field order is important. + // See https://tools.ietf.org/html/rfc7638#section-3.3 for details. + return fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s"}`, + base64.RawURLEncoding.EncodeToString(e.Bytes()), + base64.RawURLEncoding.EncodeToString(n.Bytes()), + ), nil + case *ecdsa.PublicKey: + // https://tools.ietf.org/html/rfc7518#section-6.2.1 + p := pub.Curve.Params() + n := p.BitSize / 8 + if p.BitSize%8 != 0 { + n++ + } + x := pub.X.Bytes() + if n > len(x) { + x = append(make([]byte, n-len(x)), x...) + } + y := pub.Y.Bytes() + if n > len(y) { + y = append(make([]byte, n-len(y)), y...) + } + // Field order is important. + // See https://tools.ietf.org/html/rfc7638#section-3.3 for details. + return fmt.Sprintf(`{"crv":"%s","kty":"EC","x":"%s","y":"%s"}`, + p.Name, + base64.RawURLEncoding.EncodeToString(x), + base64.RawURLEncoding.EncodeToString(y), + ), nil + } + return "", errUnsupportedKey +} + +// jwsSign signs the digest using the given key. +// It returns ErrUnsupportedKey if the key type is unknown. +// The hash is used only for RSA keys. +func jwsSign(key crypto.Signer, hash crypto.Hash, digest []byte) ([]byte, error) { + switch key := key.(type) { + case *rsa.PrivateKey: + return key.Sign(rand.Reader, digest, hash) + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, key, digest) + if err != nil { + return nil, err + } + rb, sb := r.Bytes(), s.Bytes() + size := key.Params().BitSize / 8 + if size%8 > 0 { + size++ + } + sig := make([]byte, size*2) + copy(sig[size-len(rb):], rb) + copy(sig[size*2-len(sb):], sb) + return sig, nil + } + return nil, errUnsupportedKey +} + +// jwsHasher indicates suitable JWS algorithm name and a hash function +// to use for signing a digest with the provided key. +// It returns ("", 0) if the key is not supported. +func jwsHasher(key crypto.Signer) (string, crypto.Hash) { + switch key := key.(type) { + case *rsa.PrivateKey: + return "RS256", crypto.SHA256 + case *ecdsa.PrivateKey: + switch key.Params().Name { + case "P-256": + return "ES256", crypto.SHA256 + case "P-384": + return "ES384", crypto.SHA384 + case "P-521": + return "ES512", crypto.SHA512 + } + } + return "", 0 +} + +// JWKThumbprint creates a JWK thumbprint out of pub +// as specified in https://tools.ietf.org/html/rfc7638. +func JWKThumbprint(pub crypto.PublicKey) (string, error) { + jwk, err := jwkEncode(pub) + if err != nil { + return "", err + } + b := sha256.Sum256([]byte(jwk)) + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/vendor/github.com/eggsampler/acme/v2/nonce.go b/vendor/github.com/eggsampler/acme/v2/nonce.go new file mode 100644 index 000000000..2ef9aca73 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/nonce.go @@ -0,0 +1,45 @@ +package acme + +import ( + "sync" +) + +// Simple thread-safe stack impl +type nonceStack struct { + lock sync.Mutex + stack []string +} + +// Pushes a nonce to the stack. +// Doesn't push empty nonces, or if there's more than 100 nonces on the stack +func (ns *nonceStack) push(v string) { + if v == "" { + return + } + + ns.lock.Lock() + defer ns.lock.Unlock() + + if len(ns.stack) > 100 { + return + } + + ns.stack = append(ns.stack, v) +} + +// Pops a nonce from the stack. +// Returns empty string if there are no nonces +func (ns *nonceStack) pop() string { + ns.lock.Lock() + defer ns.lock.Unlock() + + n := len(ns.stack) + if n == 0 { + return "" + } + + v := ns.stack[n-1] + ns.stack = ns.stack[:n-1] + + return v +} diff --git a/vendor/github.com/eggsampler/acme/v2/options.go b/vendor/github.com/eggsampler/acme/v2/options.go new file mode 100644 index 000000000..ff19867fd --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/options.go @@ -0,0 +1,70 @@ +package acme + +import ( + "crypto/tls" + "errors" + "net/http" + "time" +) + +// OptionFunc function prototype for passing options to NewClient +type OptionFunc func(client *Client) error + +// WithHTTPTimeout sets a timeout on the http client used by the Client +func WithHTTPTimeout(duration time.Duration) OptionFunc { + return func(client *Client) error { + client.httpClient.Timeout = duration + return nil + } +} + +// WithInsecureSkipVerify sets InsecureSkipVerify on the http client transport tls client config used by the Client +func WithInsecureSkipVerify() OptionFunc { + return func(client *Client) error { + client.httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + return nil + } +} + +// WithUserAgentSuffix appends a user agent suffix for http requests to acme resources +func WithUserAgentSuffix(userAgentSuffix string) OptionFunc { + return func(client *Client) error { + client.userAgentSuffix = userAgentSuffix + return nil + } +} + +// WithAcceptLanguage sets an Accept-Language header on http requests +func WithAcceptLanguage(acceptLanguage string) OptionFunc { + return func(client *Client) error { + client.acceptLanguage = acceptLanguage + return nil + } +} + +// WithRetryCount sets the number of times the acme client retries when receiving an api error (eg, nonce failures, etc). +// Default: 5 +func WithRetryCount(retryCount int) OptionFunc { + return func(client *Client) error { + if retryCount < 1 { + return errors.New("retryCount must be > 0") + } + client.retryCount = retryCount + return nil + } +} + +// WithHTTPClient Allows setting a custom http client for acme connections +func WithHTTPClient(httpClient *http.Client) OptionFunc { + return func(client *Client) error { + if httpClient == nil { + return errors.New("client must not be nil") + } + client.httpClient = httpClient + return nil + } +} diff --git a/vendor/github.com/eggsampler/acme/v2/order.go b/vendor/github.com/eggsampler/acme/v2/order.go new file mode 100644 index 000000000..e16ca2226 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/order.go @@ -0,0 +1,141 @@ +package acme + +import ( + "net/http" + + "encoding/base64" + + "fmt" + + "time" + + "errors" + + "crypto/x509" +) + +// NewOrder initiates a new order for a new certificate. +func (c Client) NewOrder(account Account, identifiers []Identifier) (Order, error) { + newOrderReq := struct { + Identifiers []Identifier `json:"identifiers"` + }{ + Identifiers: identifiers, + } + newOrderResp := Order{} + resp, err := c.post(c.dir.NewOrder, account.URL, account.PrivateKey, newOrderReq, &newOrderResp, http.StatusCreated) + if err != nil { + return newOrderResp, err + } + + newOrderResp.URL = resp.Header.Get("Location") + + return newOrderResp, nil +} + +// NewOrderDomains is a wrapper for NewOrder(AcmeAccount, []AcmeIdentifiers) +// Creates a dns identifier for each provided domain +func (c Client) NewOrderDomains(account Account, domains ...string) (Order, error) { + if len(domains) == 0 { + return Order{}, errors.New("acme: no domains provided") + } + + var ids []Identifier + for _, d := range domains { + ids = append(ids, Identifier{Type: "dns", Value: d}) + } + + return c.NewOrder(account, ids) +} + +// FetchOrder fetches an existing order given an order url. +func (c Client) FetchOrder(account Account, orderURL string) (Order, error) { + orderResp := Order{ + URL: orderURL, // boulder response doesn't seem to contain location header for this request + } + _, err := c.post(orderURL, account.URL, account.PrivateKey, "", &orderResp, http.StatusOK) + + return orderResp, err +} + +// Helper function to determine whether an order is "finished" by it's status. +func checkFinalizedOrderStatus(order Order) (bool, error) { + switch order.Status { + case "invalid": + // "invalid": The certificate will not be issued. Consider this + // order process abandoned. + if order.Error.Type != "" { + return true, order.Error + } + return true, errors.New("acme: finalized order is invalid, no error provided") + + case "pending": + // "pending": The server does not believe that the client has + // fulfilled the requirements. Check the "authorizations" array for + // entries that are still pending. + return true, errors.New("acme: authorizations not fulfilled") + + case "ready": + // "ready": The server agrees that the requirements have been + // fulfilled, and is awaiting finalization. Submit a finalization + // request. + return true, errors.New("acme: unexpected 'ready' state") + + case "processing": + // "processing": The certificate is being issued. Send a GET request + // after the time given in the "Retry-After" header field of the + // response, if any. + return false, nil + + case "valid": + // "valid": The server has issued the certificate and provisioned its + // URL to the "certificate" field of the order. Download the + // certificate. + return true, nil + + default: + return true, fmt.Errorf("acme: unknown order status: %s", order.Status) + } +} + +// FinalizeOrder indicates to the acme server that the client considers an order complete and "finalizes" it. +// If the server believes the authorizations have been filled successfully, a certificate should then be available. +// This function assumes that the order status is "ready". +func (c Client) FinalizeOrder(account Account, order Order, csr *x509.CertificateRequest) (Order, error) { + finaliseReq := struct { + Csr string `json:"csr"` + }{ + Csr: base64.RawURLEncoding.EncodeToString(csr.Raw), + } + + resp, err := c.post(order.Finalize, account.URL, account.PrivateKey, finaliseReq, &order, http.StatusOK) + if err != nil { + return order, err + } + + order.URL = resp.Header.Get("Location") + + if finished, err := checkFinalizedOrderStatus(order); finished { + return order, err + } + + pollInterval, pollTimeout := c.getPollingDurations() + end := time.Now().Add(pollTimeout) + for { + if time.Now().After(end) { + return order, errors.New("acme: finalized order timeout") + } + time.Sleep(pollInterval) + + if _, err := c.post(order.URL, account.URL, account.PrivateKey, "", &order, http.StatusOK); err != nil { + // i dont think it's worth exiting the loop on this error + // it could just be connectivity issue thats resolved before the timeout duration + continue + } + + order.URL = resp.Header.Get("Location") + + if finished, err := checkFinalizedOrderStatus(order); finished { + return order, err + } + } +} diff --git a/vendor/github.com/eggsampler/acme/v2/problem.go b/vendor/github.com/eggsampler/acme/v2/problem.go new file mode 100644 index 000000000..2006e03f9 --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/problem.go @@ -0,0 +1,61 @@ +package acme + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// Problem represents an error returned by an acme server. +type Problem struct { + Status int `json:"status"` + + Type string `json:"type"` + Detail string `json:"detail"` + Instance string `json:"instance"` + SubProblems []struct { + Type string `json:"type"` + Detail string `json:"detail"` + Identifier Identifier + } `json:"subproblems"` +} + +// Returns a human readable error string. +func (err Problem) Error() string { + s := fmt.Sprintf("acme: error code %d %q: %s", err.Status, err.Type, err.Detail) + if len(err.SubProblems) > 0 { + for _, v := range err.SubProblems { + s += fmt.Sprintf(", problem %q: %s", v.Type, v.Detail) + } + } + if err.Instance != "" { + s += ", url: " + err.Instance + } + return s +} + +// Helper function to determine if a response contains an expected status code, or otherwise an error object. +func checkError(resp *http.Response, expectedStatuses ...int) error { + for _, statusCode := range expectedStatuses { + if resp.StatusCode == statusCode { + return nil + } + } + + if resp.StatusCode < 400 || resp.StatusCode >= 600 { + return fmt.Errorf("acme: expected status codes: %d, got: %d %s", expectedStatuses, resp.StatusCode, resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("acme: error reading error body: %v", err) + } + + acmeError := Problem{} + if err := json.Unmarshal(body, &acmeError); err != nil { + return fmt.Errorf("acme: parsing error body: %v - %s", err, string(body)) + } + + return acmeError +} diff --git a/vendor/github.com/eggsampler/acme/v2/types.go b/vendor/github.com/eggsampler/acme/v2/types.go new file mode 100644 index 000000000..6c20d890f --- /dev/null +++ b/vendor/github.com/eggsampler/acme/v2/types.go @@ -0,0 +1,155 @@ +package acme + +import ( + "crypto" + "errors" + "net/http" + "time" +) + +// Different possible challenge types provided by an ACME server. +const ( + ChallengeTypeDNS01 = "dns-01" + ChallengeTypeHTTP01 = "http-01" + ChallengeTypeTLSALPN01 = "tls-alpn-01" + ChallengeTypeTLSSNI01 = "tls-sni-01" +) + +// Constants used for certificate revocation, used for RevokeCertificate +const ( + ReasonUnspecified = iota // 0 + ReasonKeyCompromise // 1 + ReasonCaCompromise // 2 + ReasonAffiliationChanged // 3 + ReasonSuperseded // 4 + ReasonCessationOfOperation // 5 + ReasonCertificateHold // 6 + _ // 7 - Unused + ReasonRemoveFromCRL // 8 + ReasonPrivilegeWithdrawn // 9 + ReasonAaCompromise // 10 +) + +var ( + ErrUnsupported = errors.New("acme: unsupported") +) + +// Directory object as returned from the client's directory url upon creation of client. +type Directory struct { + NewNonce string `json:"newNonce"` // url to new nonce endpoint + NewAccount string `json:"newAccount"` // url to new account endpoint + NewOrder string `json:"newOrder"` // url to new order endpoint + NewAuthz string `json:"newAuthz"` // url to new authz endpoint + RevokeCert string `json:"revokeCert"` // url to revoke cert endpoint + KeyChange string `json:"keyChange"` // url to key change endpoint + + // meta object containing directory metadata + Meta struct { + TermsOfService string `json:"termsOfService"` + Website string `json:"website"` + CaaIdentities []string `json:"caaIdentities"` + ExternalAccountRequired bool `json:"externalAccountRequired"` + } `json:"meta"` + + // Directory url provided when creating a new acme client. + URL string `json:"-"` +} + +// Client structure to interact with an ACME server. +// This is typically how most, if not all, of the communication between the client and server occurs. +type Client struct { + httpClient *http.Client + nonces *nonceStack + dir Directory + userAgentSuffix string + acceptLanguage string + retryCount int + + // The amount of total time the Client will wait at most for a challenge to be updated or a certificate to be issued. + // Default 30 seconds if duration is not set or if set to 0. + PollTimeout time.Duration + + // The time between checking if a challenge has been updated or a certificate has been issued. + // Default 0.5 seconds if duration is not set or if set to 0. + PollInterval time.Duration +} + +// Account structure representing fields in an account object. +type Account struct { + Status string `json:"status"` + Contact []string `json:"contact"` + TermsOfServiceAgreed bool `json:"onlyReturnExisting"` + Orders string `json:"orders"` + + // Provided by the Location http header when creating a new account or fetching an existing account. + URL string `json:"-"` + + // The private key used to create or fetch the account. + // Not fetched from server. + PrivateKey crypto.Signer `json:"-"` + + // SHA-256 digest JWK_Thumbprint of the account key. + // Used in updating challenges, see: https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-8.1 + Thumbprint string `json:"-"` +} + +// Identifier object used in order and authorization objects +type Identifier struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// Order object returned when fetching or creating a new order. +type Order struct { + Status string `json:"status"` + Expires time.Time `json:"expires"` + Identifiers []Identifier `json:"identifiers"` + Authorizations []string `json:"authorizations"` + Error Problem `json:"error"` + Finalize string `json:"finalize"` + Certificate string `json:"certificate"` + + // URL for the order object. + // Provided by the rel="Location" Link http header + URL string `json:"-"` +} + +// Authorization object returned when fetching an authorization in an order. +type Authorization struct { + Identifier Identifier `json:"identifier"` + Status string `json:"status"` + Expires time.Time `json:"expires"` + Challenges []Challenge `json:"challenges"` + Wildcard bool `json:"wildcard"` + + // For convenience access to the provided challenges + ChallengeMap map[string]Challenge `json:"-"` + ChallengeTypes []string `json:"-"` + + URL string `json:"-"` +} + +// Challenge object fetched in an authorization or directly from the challenge url. +type Challenge struct { + Type string `json:"type"` + URL string `json:"url"` + Status string `json:"status"` + Validated string `json:"validated"` + Error Problem `json:"error"` + + // Based on the challenge used + Token string `json:"token"` + KeyAuthorization string `json:"keyAuthorization"` + + // Authorization url provided by the rel="up" Link http header + AuthorizationURL string `json:"-"` +} + +// OrderList of challenge objects. +type OrderList struct { + Orders []string `json:"orders"` + + // Order list pagination, url to next orders. + // Provided by the rel="next" Link http header + Next string `json:"-"` +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 098eb098d..82cea98d9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -19,6 +19,8 @@ github.com/cloudflare/cfssl/csr github.com/cloudflare/cfssl/info github.com/cloudflare/cfssl/crypto/pkcs7 github.com/cloudflare/cfssl/helpers/derhelpers +# github.com/eggsampler/acme/v2 v2.0.1 +github.com/eggsampler/acme/v2 # github.com/go-sql-driver/mysql v0.0.0-20170715192408-3955978caca4 github.com/go-sql-driver/mysql # github.com/golang/mock v1.2.0