diff --git a/acme/client.go b/acme/client.go index 2d31cb56..adb17a5e 100644 --- a/acme/client.go +++ b/acme/client.go @@ -2,6 +2,7 @@ package acme import ( "crypto/rsa" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -34,7 +35,7 @@ type User interface { type solver interface { CanSolve() bool - Solve(challenge challenge, domain string) + Solve(challenge challenge, domain string) error } // Client is the user-friendy way to ACME @@ -42,11 +43,12 @@ type Client struct { regURL string user User jws *jws + keyBits int Solvers map[string]solver } // NewClient creates a new client for the set user. -func NewClient(caURL string, usr User, optPort string) *Client { +func NewClient(caURL string, usr User, keyBits int, optPort string) *Client { if err := usr.GetPrivateKey().Validate(); err != nil { logger().Fatalf("Could not validate the private account key of %s -> %v", usr.GetEmail(), err) } @@ -55,10 +57,9 @@ func NewClient(caURL string, usr User, optPort string) *Client { // REVIEW: best possibility? solvers := make(map[string]solver) - solvers["simpleHttp"] = &simpleHTTPChallenge{jws: jws} - solvers["dvsni"] = &dvsniChallenge{} + solvers["simpleHttps"] = &simpleHTTPChallenge{jws: jws, optPort: optPort} - return &Client{regURL: caURL, user: usr, jws: jws} + return &Client{regURL: caURL, user: usr, jws: jws, keyBits: keyBits, Solvers: solvers} } // Register the current account to the ACME server. @@ -112,33 +113,32 @@ func (c *Client) AgreeToTos() error { return err } - logger().Printf("Agreement: %s", string(jsonBytes)) - resp, err := c.jws.post(c.user.GetRegistration().URI, jsonBytes) if err != nil { return err } - logResponseBody(resp) - if resp.StatusCode != http.StatusAccepted { return fmt.Errorf("The server returned %d but we expected %d", resp.StatusCode, http.StatusAccepted) } - logResponseHeaders(resp) - logResponseBody(resp) - return nil } // ObtainCertificates tries to obtain certificates from the CA server -// using the challenges it has configured. It also tries to do multiple -// certificate processings at the same time in parallel. -func (c *Client) ObtainCertificates(domains []string) error { - +// using the challenges it has configured. The returned certificates are +// DER encoded byte slices. +func (c *Client) ObtainCertificates(domains []string) ([]CertificateResource, error) { + logger().Print("Obtaining certificates...") challenges := c.getChallenges(domains) - c.solveChallenges(challenges) - return nil + err := c.solveChallenges(challenges) + if err != nil { + return nil, err + } + + logger().Print("Validations succeeded. Getting certificates") + + return c.requestCertificates(challenges) } // Looks through the challenge combinations to find a solvable match. @@ -149,7 +149,11 @@ func (c *Client) solveChallenges(challenges []*authorizationResource) error { // no solvers - no solving if solvers := c.chooseSolvers(authz.Body); solvers != nil { for i, solver := range solvers { - solver.Solve(authz.Body.Challenges[i], authz.Domain) + // TODO: do not immediately fail if one domain fails to validate. + err := solver.Solve(authz.Body.Challenges[i], authz.Domain) + if err != nil { + return err + } } } else { return fmt.Errorf("Could not determine solvers for %s", authz.Domain) @@ -164,9 +168,11 @@ func (c *Client) solveChallenges(challenges []*authorizationResource) error { func (c *Client) chooseSolvers(auth authorization) map[int]solver { for _, combination := range auth.Combinations { solvers := make(map[int]solver) - for i := range combination { - if solver, ok := c.Solvers[auth.Challenges[i].Type]; ok { - solvers[i] = solver + for _, idx := range combination { + if solver, ok := c.Solvers[auth.Challenges[idx].Type]; ok { + solvers[idx] = solver + } else { + logger().Printf("Could not find solver for: %s", auth.Challenges[idx].Type) } } @@ -213,7 +219,7 @@ func (c *Client) getChallenges(domains []string) []*authorizationResource { errc <- err } - resc <- &authorizationResource{Body: authz, NewCertURL: links["next"], Domain: domain} + resc <- &authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: resp.Header.Get("Location"), Domain: domain} }(domain) } @@ -234,6 +240,47 @@ func (c *Client) getChallenges(domains []string) []*authorizationResource { return responses } +func (c *Client) requestCertificates(challenges []*authorizationResource) ([]CertificateResource, error) { + var certs []CertificateResource + for _, authz := range challenges { + privKey, err := generatePrivateKey(c.keyBits) + if err != nil { + return nil, err + } + + csr, err := generateCsr(privKey, authz.Domain) + if err != nil { + return nil, err + } + csrString := base64.URLEncoding.EncodeToString(csr) + jsonBytes, err := json.Marshal(csrMessage{Csr: csrString}) + if err != nil { + return nil, err + } + + resp, err := c.jws.post(authz.NewCertURL, jsonBytes) + if err != nil { + return nil, err + } + + logResponseHeaders(resp) + + if resp.Header.Get("Content-Type") != "application/pkix-cert" { + return nil, fmt.Errorf("The server returned an unexpected content-type header: %s - expected %s", resp.Header.Get("Content-Type"), "application/pkix-cert") + } + + cert, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + privateKeyPem := pemEncode(privKey) + + certs = append(certs, CertificateResource{Domain: authz.Domain, CertURL: resp.Header.Get("Location"), PrivateKey: privateKeyPem, Certificate: cert}) + } + return certs, nil +} + func logResponseHeaders(resp *http.Response) { logger().Println(resp.Status) for k, v := range resp.Header { diff --git a/acme/messages.go b/acme/messages.go index c8cdc36a..4f99bcff 100644 --- a/acme/messages.go +++ b/acme/messages.go @@ -49,8 +49,23 @@ type identifier struct { } type challenge struct { - Type string `json:"type"` - Status string `json:"status"` - URI string `json:"uri"` - Token string `json:"token"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + URI string `json:"uri,omitempty"` + Token string `json:"token,omitempty"` + Path string `json:"path,omitempty"` +} + +type csrMessage struct { + Csr string `json:"csr"` +} + +// CertificateResource represents a CA issued certificate. +// PrivateKey and Certificate are both already PEM encoded +// and can be directly written to disk. +type CertificateResource struct { + Domain string + CertURL string + PrivateKey []byte + Certificate []byte } diff --git a/acme/simple_http_challenge.go b/acme/simple_http_challenge.go index afaa1c40..c99ea10a 100644 --- a/acme/simple_http_challenge.go +++ b/acme/simple_http_challenge.go @@ -1,12 +1,155 @@ package acme -type simpleHTTPChallenge struct{} +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "time" +) + +type simpleHTTPChallenge struct { + jws *jws + optPort string +} func (s *simpleHTTPChallenge) CanSolve() bool { return true } -func (s *simpleHTTPChallenge) Solve(challenge challenge) { -func (s *simpleHTTPChallenge) Solve(chlng challenge, domain string) { +func (s *simpleHTTPChallenge) Solve(chlng challenge, domain string) error { + logger().Print("Trying to solve SimpleHTTPS") + + responseToken := getRandomString(15) + listener, err := s.startHTTPSServer(domain, chlng.Token, responseToken) + if err != nil { + return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) + } + + jsonBytes, err := json.Marshal(challenge{Type: chlng.Type, Path: responseToken}) + if err != nil { + return errors.New("Failed to marshal network message...") + } + + resp, err := s.jws.post(chlng.URI, jsonBytes) + if err != nil { + return fmt.Errorf("Failed to post JWS message. -> %v", err) + } + + var challengeResponse challenge +loop: + for { + decoder := json.NewDecoder(resp.Body) + decoder.Decode(&challengeResponse) + + switch challengeResponse.Status { + case "valid": + logger().Print("The server validated our request") + listener.Close() + 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 +} + +// Starts a temporary HTTPS server on port 443. As soon as the challenge passed validation, +// this server will get shut down. The certificate generated here is only held in memory. +func (s *simpleHTTPChallenge) startHTTPSServer(domain string, token string, responseToken string) (net.Listener, error) { + tempPrivKey, err := generatePrivateKey(2048) + if err != nil { + return nil, err + } + tempCertPEM, err := generateCert(tempPrivKey, domain) + if err != nil { + return nil, err + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(tempPrivKey)}) + tempKeyPair, err := tls.X509KeyPair( + tempCertPEM, + pemBytes) + if err != nil { + logger().Print("error here!") + return nil, err + } + + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{tempKeyPair} + + path := "/.well-known/acme-challenge/" + responseToken + + port := ":443" + if s.optPort != "" { + port = ":" + s.optPort + } + tlsListener, err := tls.Listen("tcp", port, tlsConf) + if err != nil { + logger().Fatalf("Could not start HTTP listener! -> %v", err) + } + + http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if r.Host == domain && r.Method == "GET" { + w.Write([]byte(token)) + } + }) + + go http.Serve(tlsListener, nil) + + return tlsListener, nil +} + +func getRandomString(length int) string { + const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + var bytes = make([]byte, length) + rand.Read(bytes) + for i, b := range bytes { + bytes[i] = alphanum[b%byte(len(alphanum))] + } + return string(bytes) +} + +func generateCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "ACME Challenge TEMP", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365), + + KeyUsage: x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + DNSNames: []string{domain}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + return nil, err + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil } diff --git a/cli_handlers.go b/cli_handlers.go index 14e6030d..918ade17 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -30,7 +30,7 @@ func run(c *cli.Context) { } acc := NewAccount(c.GlobalString("email"), conf) - client := acme.NewClient(c.GlobalString("server"), acc, conf.OptPort()) + client := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits(), conf.OptPort()) if acc.Registration == nil { reg, err := client.Register() if err != nil {