diff --git a/README.md b/README.md index c3a53175..17c76016 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Current features: - [ ] Initiating account recovery - Identifier validation challenges - [x] HTTP (http-01) - - [ ] TLS with Server Name Indication (tls-sni-01) + - [x] TLS with Server Name Indication (tls-sni-01) - [ ] Proof of Possession of a Prior Key (proofOfPossession-01) - [ ] DNS (dns-01) - Implemented in branch, blocked by upstream. - [x] Certificate bundling diff --git a/acme/client.go b/acme/client.go index 5d0f6982..6897ed4b 100644 --- a/acme/client.go +++ b/acme/client.go @@ -100,6 +100,7 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client // 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} return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } @@ -409,7 +410,7 @@ func (c *Client) chooseSolvers(auth authorization, domain string) map[int]solver if solver, ok := c.solvers[auth.Challenges[idx].Type]; ok { solvers[idx] = solver } else { - logf("[ERROR] acme: Could not find solver for: %s", auth.Challenges[idx].Type) + logf("[INFO] acme: Could not find solver for: %s", auth.Challenges[idx].Type) } } diff --git a/acme/client_test.go b/acme/client_test.go index 0c37d536..3e97e187 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -43,7 +43,7 @@ func TestNewClient(t *testing.T) { t.Errorf("Expected keyBits to be %d but was %d", keyBits, client.keyBits) } - if expected, actual := 1, len(client.solvers); actual != expected { + if expected, actual := 2, len(client.solvers); actual != expected { t.Fatalf("Expected %d solver(s), got %d", expected, actual) } diff --git a/acme/dvsni_challenge.go b/acme/dvsni_challenge.go deleted file mode 100644 index 8d2a213b..00000000 --- a/acme/dvsni_challenge.go +++ /dev/null @@ -1 +0,0 @@ -package acme diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index cfd9fe2a..bf085ffd 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -21,7 +21,7 @@ func TestHTTPNonRootBind(t *testing.T) { solver := &httpChallenge{jws: jws} clientChallenge := challenge{Type: "http01", Status: "pending", URI: "localhost:4000", Token: "http1"} - // validate error on non-root bind to 443 + // 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 { diff --git a/acme/messages.go b/acme/messages.go index 2cc2e583..2ec0bb74 100644 --- a/acme/messages.go +++ b/acme/messages.go @@ -80,6 +80,7 @@ type challenge struct { Token string `json:"token,omitempty"` KeyAuthorization string `json:"keyAuthorization,omitempty"` TLS bool `json:"tls,omitempty"` + Iterations int `json:"n,omitempty"` } type csrMessage struct { diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go new file mode 100644 index 00000000..57ddd7d6 --- /dev/null +++ b/acme/tls_sni_challenge.go @@ -0,0 +1,153 @@ +package acme + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "time" +) + +type tlsSNIChallenge struct { + jws *jws + optPort string + start chan net.Listener + end chan error +} + +func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { + // FIXME: https://github.com/ietf-wg-acme/acme/pull/22 + // Currently we implement this challenge to track boulder, not the current spec! + + logf("[INFO] acme: Trying to solve TLS-SNI-01") + + t.start = make(chan net.Listener) + t.end = make(chan error) + + // Generate the Key Authorization for the challenge + keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey) + if err != nil { + return err + } + + certificate, err := t.generateCertificate(keyAuth) + if err != nil { + return err + } + + go t.startSNITLSServer(certificate) + var listener net.Listener + select { + case listener = <-t.start: + break + case err := <-t.end: + return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) + } + + // Make sure we properly close the HTTP server before we return + defer func() { + listener.Close() + err = <-t.end + close(t.start) + close(t.end) + }() + + jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) + if err != nil { + return errors.New("Failed to marshal network message...") + } + + // Tell the server we handle TLS-SNI-01 + resp, err := t.jws.post(chlng.URI, jsonBytes) + if err != nil { + return fmt.Errorf("Failed to post JWS message. -> %v", err) + } + + // After the path is sent, the ACME server will access our server. + // Repeatedly check the server for an updated status on our request. + var challengeResponse challenge +Loop: + for { + if resp.StatusCode >= http.StatusBadRequest { + return handleHTTPError(resp) + } + + err = json.NewDecoder(resp.Body).Decode(&challengeResponse) + resp.Body.Close() + if err != nil { + return err + } + + switch challengeResponse.Status { + case "valid": + logf("The server validated our request") + break Loop + case "pending": + break + case "invalid": + return errors.New("The server could not validate our request.") + default: + return errors.New("The server returned an unexpected state.") + } + + time.Sleep(1 * time.Second) + resp, err = http.Get(chlng.URI) + } + + return nil +} + +func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { + + zBytes := sha256.Sum256([]byte(keyAuth)) + z := hex.EncodeToString(zBytes[:sha256.Size]) + + // generate a new RSA key for the certificates + tempPrivKey, err := generatePrivateKey(rsakey, 2048) + if err != nil { + return tls.Certificate{}, err + } + rsaPrivKey := tempPrivKey.(*rsa.PrivateKey) + rsaPrivPEM := pemEncode(rsaPrivKey) + + domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) + tempCertPEM, err := generatePemCert(rsaPrivKey, domain) + if err != nil { + return tls.Certificate{}, err + } + + certificate, err := tls.X509KeyPair(tempCertPEM, rsaPrivPEM) + if err != nil { + return tls.Certificate{}, err + } + + return certificate, nil +} + +func (t *tlsSNIChallenge) startSNITLSServer(cert tls.Certificate) { + + // Allow for CLI port override + port := ":443" + if t.optPort != "" { + port = ":" + t.optPort + } + + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{cert} + + tlsListener, err := tls.Listen("tcp", port, tlsConf) + if err != nil { + t.end <- err + } + // Signal successfull start + t.start <- tlsListener + + http.Serve(tlsListener, nil) + + t.end <- nil +} diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go new file mode 100644 index 00000000..41c3f9db --- /dev/null +++ b/acme/tls_sni_challenge_test.go @@ -0,0 +1,100 @@ +package acme + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "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) { + 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{ + InsecureSkipVerify: true, + }) + if err != nil { + t.Errorf("Expected to connect to challenge server without an error. %s", err.Error()) + } + + // Expect the server to only return one certificate + connState := conn.ConnectionState() + if count := len(connState.PeerCertificates); count != 1 { + t.Errorf("Expected the challenge server to return exactly one certificate but got %d", count) + } + + remoteCert := connState.PeerCertificates[0] + if count := len(remoteCert.DNSNames); count != 1 { + t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count) + } + + zBytes := sha256.Sum256([]byte(request.KeyAuthorization)) + z := hex.EncodeToString(zBytes[:sha256.Size]) + domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) + + if remoteCert.DNSNames[0] != domain { + 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) + }) + + 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()) + } +}