diff --git a/acme/client.go b/acme/client.go index 598f5692..221642d3 100644 --- a/acme/client.go +++ b/acme/client.go @@ -99,8 +99,8 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client // Add all available solvers with the right index as per ACME // spec to this map. Otherwise they won`t be found. solvers := make(map[string]solver) - solvers["http-01"] = &httpChallenge{jws: jws, optPort: optPort} - solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, optPort: optPort} + solvers["http-01"] = &httpChallenge{jws: jws, validate: validate, optPort: optPort} + solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate, optPort: optPort} return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } @@ -671,6 +671,11 @@ func parseLinks(links []string) map[string]string { return linkMap } +var ( + pollInterval = 1 * time.Second + maxPollInterval = 15 * time.Minute +) + // validate makes the ACME server start validating a // challenge response, only returning once it is done. func validate(j *jws, uri string, chlng challenge) error { @@ -680,8 +685,7 @@ func validate(j *jws, uri string, chlng challenge) error { return err } - interval := 1 * time.Second - maxInterval := 15 * time.Minute + delay := pollInterval // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. @@ -699,10 +703,10 @@ func validate(j *jws, uri string, chlng challenge) error { } // Poll with exponential back-off. - time.Sleep(interval) - interval *= 2 - if interval > maxInterval { - interval = maxInterval + time.Sleep(delay) + delay *= 2 + if delay > maxPollInterval { + delay = maxPollInterval } if err := getJSON(uri, &challengeResponse); err != nil { diff --git a/acme/client_test.go b/acme/client_test.go index 3e97e187..3718d466 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -59,6 +60,77 @@ func TestNewClient(t *testing.T) { } } +func TestValidate(t *testing.T) { + // Disable polling delay in validate for faster tests. + pollInterval = 0 + + var statuses []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Minimal stub ACME server for validation. + w.Header().Add("Replay-Nonce", "12345") + switch r.Method { + case "HEAD": + case "POST": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + case "GET": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + default: + http.Error(w, r.Method, http.StatusMethodNotAllowed) + } + })) + defer ts.Close() + + privKey, _ := generatePrivateKey(rsakey, 512) + j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} + + tsts := []struct { + name string + statuses []string + want string + }{ + {"POST-unexpected", []string{"weird"}, "unexpected"}, + {"POST-valid", []string{"valid"}, ""}, + {"POST-invalid", []string{"invalid"}, "not validate"}, + {"GET-unexpected", []string{"pending", "weird"}, "unexpected"}, + {"GET-valid", []string{"pending", "valid"}, ""}, + {"GET-invalid", []string{"pending", "invalid"}, "not validate"}, + } + + for _, tst := range tsts { + statuses = tst.statuses + if err := validate(j, ts.URL, challenge{Type: "http-01", Token: "token"}); err == nil && tst.want != "" { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } else if err != nil && !strings.Contains(err.Error(), tst.want) { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } + } +} + +// writeJSONResponse marshals the body as JSON and writes it to the response. +func writeJSONResponse(w http.ResponseWriter, body interface{}) { + bs, err := json.Marshal(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(bs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// stubValidate is like validate, except it does nothing. +func stubValidate(j *jws, uri string, chlng challenge) error { + return nil +} + type mockUser struct { email string regres *RegistrationResource diff --git a/acme/http_challenge.go b/acme/http_challenge.go index d313b1ab..5ebf0dd6 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -8,8 +8,9 @@ import ( ) type httpChallenge struct { - jws *jws - optPort string + jws *jws + validate func(j *jws, uri string, chlng challenge) error + optPort string } func (s *httpChallenge) Solve(chlng challenge, domain string) error { @@ -56,5 +57,5 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { go http.Serve(listener, mux) - return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) + return s.validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index bf085ffd..759967d0 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -2,263 +2,56 @@ package acme import ( "crypto/rsa" - "crypto/tls" - "encoding/json" "io/ioutil" "net/http" - "net/http/httptest" - "regexp" "strings" "testing" - - "github.com/square/go-jose" ) -func TestHTTPNonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &httpChallenge{jws: jws} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "localhost:4000", Token: "http1"} - - // validate error on non-root bind to 80 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTP server for challenge -> listen tcp :80: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestHTTPShortRSA(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http2"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Failed to post JWS message. -> crypto/rsa: message too long for RSA public key size" - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPConnectionRefusal(t *testing.T) { +func TestHTTPChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http3"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - reg := "/Failed to post JWS message\\. -> Post http:\\/\\/localhost:4000: dial tcp 127\\.0\\.0\\.1:4000: (getsockopt: )?connection refused/g" - test2 := "Failed to post JWS message. -> Post http://localhost:4000: dial tcp 127.0.0.1:4000: connection refused" - r, _ := regexp.Compile(reg) - if r.MatchString(err.Error()) && r.MatchString(test2) { - t.Errorf("Expected \"%s\" to match %s", err.Error(), reg) - } - } -} - -func TestHTTPUnexpectedServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"what\",\"uri\":\"http://some.url\",\"token\":\"http4\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http4"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "The server returned an unexpected state." - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPChallengeServerUnexpectedDomain(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - client := &http.Client{Transport: tr} - req, _ := client.Get("https://localhost:23456/.well-known/acme-challenge/" + "htto5") - reqBytes, _ := ioutil.ReadAll(req.Body) - if string(reqBytes) != "TEST" { - t.Error("Expected http01 server to return string TEST on unexpected domain.") - } - } - - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http5\"}")) - })) - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http5"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } -} - -func TestHTTPServerError(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "HEAD" { - w.Header().Add("Replay-Nonce", "12345") - } else { - w.WriteHeader(http.StatusInternalServerError) - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"urn:acme:error:unauthorized\",\"detail\":\"Error creating new authz :: Syntax error\"}")) - } - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http6"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "acme: Error 500 - urn:acme:error:unauthorized - Error creating new authz :: Syntax error" - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPInvalidServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http7\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http7"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "The server could not validate our request." - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPValidServerResponse(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http8"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) - } -} - -func TestHTTPValidFull(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(nil) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23457"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http9"} - - // Validate server on port 23456 which responds appropriately - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "http-01", Token: "http1"} + mockValidate := func(_ *jws, _ string, chlng challenge) error { + uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token + resp, err := http.Get(uri) if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - client := &http.Client{Transport: transport} - - reqURL := "http://localhost:23457/.well-known/acme-challenge/" + clientChallenge.Token - t.Logf("Request URL is: %s", reqURL) - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - t.Error(err) - } - req.Host = "127.0.0.1" - resp, err := client.Do(req) - if err != nil { - t.Errorf("Expected the solver to listen on port 23457 -> %v", err) + return err } defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) + if want := "text/plain"; resp.Header.Get("Content-Type") != want { + t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } bodyStr := string(body) - if resp.Header.Get("Content-Type") != "text/plain" { - t.Errorf("Expected server to respond with content type text/plain.") + if bodyStr != chlng.KeyAuthorization { + t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } - tokenRegex := regexp.MustCompile("^[\\w-]{43}$") - parts := strings.Split(bodyStr, ".") + return nil + } + solver := &httpChallenge{jws: j, validate: mockValidate, optPort: "23457"} - if len(parts) != 2 { - t.Errorf("Expected server token to be a composite of two strings, seperated by a dot") - } - - if parts[0] != clientChallenge.Token { - t.Errorf("Expected the first part of the server token to be the challenge token.") - } - - if !tokenRegex.MatchString(parts[1]) { - t.Errorf("Expected the second part of the server token to be a properly formatted key authorization") - } - - valid := challenge{Type: "http01", Status: "valid", URI: ts.URL, Token: "1234567812"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +func TestHTTPChallengeInvalidPort(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 128) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "http-01", Token: "http2"} + solver := &httpChallenge{jws: j, validate: stubValidate, optPort: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Error("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 5d963954..e9ce68dd 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -10,8 +10,9 @@ import ( ) type tlsSNIChallenge struct { - jws *jws - optPort string + jws *jws + validate func(j *jws, uri string, chlng challenge) error + optPort string } func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { @@ -48,7 +49,7 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { go http.Serve(listener, nil) - return validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) + return t.validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go index 41c3f9db..c90d9ab3 100644 --- a/acme/tls_sni_challenge_test.go +++ b/acme/tls_sni_challenge_test.go @@ -5,61 +5,17 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" - "encoding/json" "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" + "strings" "testing" - - "github.com/square/go-jose" ) -func TestTLSSNINonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &tlsSNIChallenge{jws: jws} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: "localhost:4000", Token: "tls1"} - - // validate error on non-root bind to 443 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTPS server for challenge -> listen tcp :443: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestTLSSNI(t *testing.T) { +func TestTLSSNIChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - optPort := "5001" - - ts := httptest.NewServer(nil) - - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) - if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - conn, err := tls.Dial("tcp", "localhost:"+optPort, &tls.Config{ + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni1"} + mockValidate := func(_ *jws, _ string, chlng challenge) error { + conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ InsecureSkipVerify: true, }) if err != nil { @@ -77,7 +33,7 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count) } - zBytes := sha256.Sum256([]byte(request.KeyAuthorization)) + zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) z := hex.EncodeToString(zBytes[:sha256.Size]) domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) @@ -85,16 +41,24 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate DNSName to match %s but was %s", domain, remoteCert.DNSNames[0]) } - valid := challenge{Type: "tls-sni-01", Status: "valid", URI: ts.URL, Token: "tls1"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) + return nil + } + solver := &tlsSNIChallenge{jws: j, validate: mockValidate, optPort: "23457"} - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &tlsSNIChallenge{jws: jws, optPort: optPort} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: ts.URL, Token: "tls1"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Error("UNEXPECTED: Expected Solve to return no error but the error was %s.", err.Error()) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +func TestTLSSNIChallengeInvalidPort(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 128) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni2"} + solver := &tlsSNIChallenge{jws: j, validate: stubValidate, optPort: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Error("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } }