From 88343688bb370571259f1a6c98274757b61e3bb1 Mon Sep 17 00:00:00 2001 From: Alex Vaghin Date: Sat, 31 Aug 2019 21:44:59 +0200 Subject: [PATCH] acme: implement new order-based issuance methods The order based issuance flow is different from pre-authorization in that users tell upfront which identifiers they want a future certificate to contain and the CA responds with a set of authorizations to satisfy. Similar to pre-authorization where users start with Client's Authorize method, fulfill challenges and then call GetAuthorization or WaitAuthorization, the order based flow starts with AuthorizeOrder and then GetOrder or WaitOrder. Once all order authorizations are satisfied, users can call CreateOrderCert, as opposed to the old CreateCert, and FetchCert as before. The new method implementation and updates to the existing methods is in golang.org/cl/194379. More on order based flow can be found in https://tools.ietf.org/html/rfc8555#section-7.4. Updates golang/go#21081 Change-Id: I37c37203b50785d7681f65f815d7b19d9c15b96d Reviewed-on: https://go-review.googlesource.com/c/crypto/+/192779 Run-TryBot: Alex Vaghin TryBot-Result: Gobot Gobot Reviewed-by: Filippo Valsorda --- acme/acme_test.go | 2 +- acme/jws.go | 17 +++- acme/rfc8555.go | 143 +++++++++++++++++++++++++++++++- acme/rfc8555_test.go | 189 +++++++++++++++++++++++++++++++++++++++++++ acme/types.go | 164 +++++++++++++++++++++++++++++++------ 5 files changed, 486 insertions(+), 29 deletions(-) diff --git a/acme/acme_test.go b/acme/acme_test.go index f171f8d79e..e17f3751f5 100644 --- a/acme/acme_test.go +++ b/acme/acme_test.go @@ -900,7 +900,7 @@ func TestFetchCertRetry(t *testing.T) { func TestFetchCertCancel(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "0") - w.WriteHeader(http.StatusAccepted) + w.WriteHeader(http.StatusBadRequest) })) defer ts.Close() ctx, cancel := context.WithCancel(context.Background()) diff --git a/acme/jws.go b/acme/jws.go index f8bc2c4679..cac8b6786e 100644 --- a/acme/jws.go +++ b/acme/jws.go @@ -24,6 +24,12 @@ type keyID string // See jwsEncodeJSON for details. const noKeyID = keyID("") +// noPayload indicates jwsEncodeJSON will encode zero-length octet string +// in a JWS request. This is called POST-as-GET in RFC 8555 and is used to make +// authenticated GET requests via POSTing with an empty payload. +// See https://tools.ietf.org/html/rfc8555#section-6.3 for more details. +const noPayload = "" + // jwsEncodeJSON signs claimset using provided key and a nonce. // The result is serialized in JSON format containing either kid or jwk // fields based on the provided keyID value. @@ -50,11 +56,14 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid keyID, nonce, ur phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, kid, nonce, url) } phead = base64.RawURLEncoding.EncodeToString([]byte(phead)) - cs, err := json.Marshal(claimset) - if err != nil { - return nil, err + var payload string + if claimset != noPayload { + cs, err := json.Marshal(claimset) + if err != nil { + return nil, err + } + payload = base64.RawURLEncoding.EncodeToString(cs) } - payload := base64.RawURLEncoding.EncodeToString(cs) hash := sha.New() hash.Write([]byte(phead + "." + payload)) sig, err := jwsSign(key, sha, hash.Sum(nil)) diff --git a/acme/rfc8555.go b/acme/rfc8555.go index 51839a0723..7517b058bf 100644 --- a/acme/rfc8555.go +++ b/acme/rfc8555.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) // DeactivateReg permanently disables an existing account associated with c.Key. @@ -111,7 +112,7 @@ func responseAccount(res *http.Response) (*Account, error) { Orders string } if err := json.NewDecoder(res.Body).Decode(&v); err != nil { - return nil, fmt.Errorf("acme: invalid response: %v", err) + return nil, fmt.Errorf("acme: invalid account response: %v", err) } return &Account{ URI: res.Header.Get("Location"), @@ -120,3 +121,143 @@ func responseAccount(res *http.Response) (*Account, error) { OrdersURL: v.Orders, }, nil } + +// AuthorizeOrder initiates the order-based application for certificate issuance, +// as opposed to pre-authorization in Authorize. +// +// The caller then needs to fetch each required authorization with GetAuthorization +// and fulfill a challenge using Accept. Once all authorizations are satisfied, +// the caller will typically want to poll order status using WaitOrder until it's in StatusReady state. +// To finalize the order and obtain a certificate, the caller submits a CSR with CreateOrderCert. +func (c *Client) AuthorizeOrder(ctx context.Context, id []AuthzID, opt ...OrderOption) (*Order, error) { + dir, err := c.Discover(ctx) + if err != nil { + return nil, err + } + + req := struct { + Identifiers []wireAuthzID `json:"identifiers"` + NotBefore string `json:"notBefore,omitempty"` + NotAfter string `json:"notAfter,omitempty"` + }{} + for _, v := range id { + req.Identifiers = append(req.Identifiers, wireAuthzID{ + Type: v.Type, + Value: v.Value, + }) + } + for _, o := range opt { + switch o := o.(type) { + case orderNotBeforeOpt: + req.NotBefore = time.Time(o).Format(time.RFC3339) + case orderNotAfterOpt: + req.NotAfter = time.Time(o).Format(time.RFC3339) + default: + // Package's fault if we let this happen. + panic(fmt.Sprintf("unsupported order option type %T", o)) + } + } + + res, err := c.post(ctx, nil, dir.OrderURL, req, wantStatus(http.StatusCreated)) + if err != nil { + return nil, err + } + defer res.Body.Close() + return responseOrder(res) +} + +// GetOrder retrives an order identified by the given URL. +// For orders created with AuthorizeOrder, the url value is Order.URI. +// +// If a caller needs to poll an order until its status is final, +// see the WaitOrder method. +func (c *Client) GetOrder(ctx context.Context, url string) (*Order, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + + res, err := c.post(ctx, nil, url, noPayload, wantStatus(http.StatusOK)) + if err != nil { + return nil, err + } + defer res.Body.Close() + return responseOrder(res) +} + +// WaitOrder polls an order from the given URL until it is in one of the final states, +// StatusReady, StatusValid or StatusInvalid, the CA responded with a non-retryable error +// or the context is done. +// +// It returns a non-nil Order only if its Status is StatusReady or StatusValid. +// In all other cases WaitOrder returns an error. +// If the Status is StatusInvalid, the returned error is of type *WaitOrderError. +func (c *Client) WaitOrder(ctx context.Context, url string) (*Order, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + for { + res, err := c.post(ctx, nil, url, noPayload, wantStatus(http.StatusOK)) + if err != nil { + return nil, err + } + o, err := responseOrder(res) + res.Body.Close() + switch { + case err != nil: + // Skip and retry. + case o.Status == StatusInvalid: + return nil, &WaitOrderError{OrderURL: o.URI, Status: o.Status} + case o.Status == StatusReady || o.Status == StatusValid: + return o, nil + } + + d := retryAfter(res.Header.Get("Retry-After")) + if d == 0 { + // Default retry-after. + // Same reasoning as in WaitAuthorization. + d = time.Second + } + t := time.NewTimer(d) + select { + case <-ctx.Done(): + t.Stop() + return nil, ctx.Err() + case <-t.C: + // Retry. + } + } +} + +func responseOrder(res *http.Response) (*Order, error) { + var v struct { + Status string + Expires time.Time + Identifiers []wireAuthzID + NotBefore time.Time + NotAfter time.Time + Error *wireError + Authorizations []string + Finalize string + Certificate string + } + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("acme: error reading order: %v", err) + } + o := &Order{ + URI: res.Header.Get("Location"), + Status: v.Status, + Expires: v.Expires, + NotBefore: v.NotBefore, + NotAfter: v.NotAfter, + AuthzURLs: v.Authorizations, + FinalizeURL: v.Finalize, + CertURL: v.Certificate, + } + for _, id := range v.Identifiers { + o.Identifiers = append(o.Identifiers, AuthzID{Type: id.Type, Value: id.Value}) + } + if v.Error != nil { + o.Error = v.Error.error(nil /* headers */) + } + return o, nil +} diff --git a/acme/rfc8555_test.go b/acme/rfc8555_test.go index c366380d5a..a109c8d6a4 100644 --- a/acme/rfc8555_test.go +++ b/acme/rfc8555_test.go @@ -474,3 +474,192 @@ func TestRFC_GetRegOtherError(t *testing.T) { t.Errorf("GetReg: %v; want any other non-nil err", err) } } + +func TestRFC_AuthorizeOrder(t *testing.T) { + s := newACMEServer() + s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/accounts/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "valid"}`)) + }) + s.handle("/acme/new-order", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/orders/1")) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "status": "pending", + "expires": "2019-09-01T00:00:00Z", + "notBefore": "2019-08-31T00:00:00Z", + "notAfter": "2019-09-02T00:00:00Z", + "identifiers": [{"type":"dns", "value":"example.org"}], + "authorizations": [%q] + }`, s.url("/authz/1")) + }) + s.start() + defer s.close() + + cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")} + o, err := cl.AuthorizeOrder(context.Background(), DomainIDs("example.org"), + WithOrderNotBefore(time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC)), + WithOrderNotAfter(time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC)), + ) + if err != nil { + t.Fatal(err) + } + okOrder := &Order{ + URI: s.url("/orders/1"), + Status: StatusPending, + Expires: time.Date(2019, 9, 1, 0, 0, 0, 0, time.UTC), + NotBefore: time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC), + Identifiers: []AuthzID{AuthzID{Type: "dns", Value: "example.org"}}, + AuthzURLs: []string{s.url("/authz/1")}, + } + if !reflect.DeepEqual(o, okOrder) { + t.Errorf("AuthorizeOrder = %+v; want %+v", o, okOrder) + } +} + +func TestRFC_GetOrder(t *testing.T) { + s := newACMEServer() + s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/accounts/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "valid"}`)) + }) + s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/orders/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "status": "invalid", + "expires": "2019-09-01T00:00:00Z", + "notBefore": "2019-08-31T00:00:00Z", + "notAfter": "2019-09-02T00:00:00Z", + "identifiers": [{"type":"dns", "value":"example.org"}], + "authorizations": ["/authz/1"], + "finalize": "/orders/1/fin", + "certificate": "/orders/1/cert", + "error": {"type": "badRequest"} + }`)) + }) + s.start() + defer s.close() + + cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")} + o, err := cl.GetOrder(context.Background(), s.url("/orders/1")) + if err != nil { + t.Fatal(err) + } + okOrder := &Order{ + URI: s.url("/orders/1"), + Status: StatusInvalid, + Expires: time.Date(2019, 9, 1, 0, 0, 0, 0, time.UTC), + NotBefore: time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC), + Identifiers: []AuthzID{AuthzID{Type: "dns", Value: "example.org"}}, + AuthzURLs: []string{"/authz/1"}, + FinalizeURL: "/orders/1/fin", + CertURL: "/orders/1/cert", + Error: &Error{ProblemType: "badRequest"}, + } + if !reflect.DeepEqual(o, okOrder) { + t.Errorf("GetOrder = %+v\nwant %+v", o, okOrder) + } +} + +func TestRFC_WaitOrder(t *testing.T) { + for _, st := range []string{StatusReady, StatusValid} { + t.Run(st, func(t *testing.T) { + testWaitOrder(t, st) + }) + } +} + +func testWaitOrder(t *testing.T, okStatus string) { + s := newACMEServer() + s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/accounts/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "valid"}`)) + }) + var count int + s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/orders/1")) + w.WriteHeader(http.StatusOK) + s := StatusPending + if count > 0 { + s = okStatus + } + fmt.Fprintf(w, `{"status": %q}`, s) + count++ + }) + s.start() + defer s.close() + + var order *Order + var err error + done := make(chan struct{}) + go func() { + cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")} + order, err = cl.WaitOrder(context.Background(), s.url("/orders/1")) + close(done) + }() + select { + case <-time.After(3 * time.Second): + t.Fatal("WaitOrder took too long to return") + case <-done: + if err != nil { + t.Fatalf("WaitOrder: %v", err) + } + if order.Status != okStatus { + t.Errorf("order.Status = %q; want %q", order.Status, okStatus) + } + } +} + +func TestRFC_WaitOrderError(t *testing.T) { + s := newACMEServer() + s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/accounts/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "valid"}`)) + }) + var count int + s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/orders/1")) + w.WriteHeader(http.StatusOK) + s := StatusPending + if count > 0 { + s = StatusInvalid + } + fmt.Fprintf(w, `{"status": %q}`, s) + count++ + }) + s.start() + defer s.close() + + var err error + done := make(chan struct{}) + go func() { + cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")} + _, err = cl.WaitOrder(context.Background(), s.url("/orders/1")) + close(done) + }() + select { + case <-time.After(3 * time.Second): + t.Fatal("WaitOrder took too long to return") + case <-done: + if err == nil { + t.Fatal("WaitOrder returned nil error") + } + e, ok := err.(*WaitOrderError) + if !ok { + t.Fatalf("err = %v (%T); want WaitOrderError", err, err) + } + if e.OrderURL != s.url("/orders/1") { + t.Errorf("e.OrderURL = %q; want %q", e.OrderURL, s.url("/orders/1")) + } + if e.Status != StatusInvalid { + t.Errorf("e.Status = %q; want %q", e.Status, StatusInvalid) + } + } +} diff --git a/acme/types.go b/acme/types.go index 4432afbc21..c654fff683 100644 --- a/acme/types.go +++ b/acme/types.go @@ -14,10 +14,12 @@ import ( "time" ) -// ACME server response statuses used to describe Authorization and Challenge states. +// ACME status values of Account, Order, Authorization and Challenge objects. +// See https://tools.ietf.org/html/rfc8555#section-7.1.6 for details. const ( StatusDeactivated = "deactivated" StatusInvalid = "invalid" + StatusReady = "ready" StatusPending = "pending" StatusProcessing = "processing" StatusRevoked = "revoked" @@ -102,6 +104,18 @@ func (a *AuthorizationError) Error() string { return fmt.Sprintf("acme: authorization error for %s: %s", a.Identifier, strings.Join(e, "; ")) } +// WaitOrderError is returned from Client's WaitOrder. +// It indicates the order is unusable and the clients should start over with +// AuthorizeOrder. +type WaitOrderError struct { + OrderURL string + Status string +} + +func (we *WaitOrderError) Error() string { + return fmt.Sprintf("acme: wait order %s: status %s", we.OrderURL, we.Status) +} + // RateLimit reports whether err represents a rate limit error and // any Retry-After duration returned by the server. // @@ -138,7 +152,7 @@ type Account struct { Contact []string // Status indicates current account status as returned by the CA. - // Possible values are "valid", "deactivated", and "revoked". + // Possible values are StatusValid, StatusDeactivated, and StatusRevoked. Status string // OrdersURL is a URL from which a list of orders submitted by this account @@ -223,28 +237,87 @@ type Directory struct { ExternalAccountRequired bool } -// Challenge encodes a returned CA challenge. -// Its Error field may be non-nil if the challenge is part of an Authorization -// with StatusInvalid. -type Challenge struct { - // Type is the challenge type, e.g. "http-01", "tls-sni-02", "dns-01". - Type string - - // URI is where a challenge response can be posted to. +// Order represents a client's request for a certificate. +// It tracks the request flow progress through to issuance. +type Order struct { + // URI uniquely identifies an order. URI string - // Token is a random value that uniquely identifies the challenge. - Token string - - // Status identifies the status of this challenge. + // Status represents the current status of the order. + // It indicates which action the client should take. + // + // Possible values are StatusPending, StatusReady, StatusProcessing, StatusValid and StatusInvalid. + // Pending means the CA does not believe that the client has fulfilled the requirements. + // Ready indicates that the client has fulfilled all the requirements and can submit a CSR + // to obtain a certificate. This is done with Client's CreateOrderCert. + // Processing means the certificate is being issued. + // Valid indicates the CA has issued the certificate. It can be downloaded + // from the Order's CertURL. This is done with Client's FetchCert. + // Invalid means the certificate will not be issued. Users should consider this order + // abandoned. Status string - // Error indicates the reason for an authorization failure - // when this challenge was used. - // The type of a non-nil value is *Error. - Error error + // Expires is the timestamp after which CA considers this order invalid. + Expires time.Time + + // Identifiers contains all identifier objects which the order pertains to. + Identifiers []AuthzID + + // NotBefore is the requested value of the notBefore field in the certificate. + NotBefore time.Time + + // NotAfter is the requested value of the notAfter field in the certificate. + NotAfter time.Time + + // AuthzURLs represents authorizations to complete before a certificate + // for identifiers specified in the order can be issued. + // It also contains unexpired authorizations that the client has completed + // in the past. + // + // Authorization objects can be fetched using Client's GetAuthorization method. + // + // The required authorizations are dictated by CA policies. + // There may not be a 1:1 relationship between the identifiers and required authorizations. + // Required authorizations can be identified by their StatusPending status. + // + // For orders in the StatusValid or StatusInvalid state these are the authorizations + // which were completed. + AuthzURLs []string + + // FinalizeURL is the endpoint at which a CSR is submitted to obtain a certificate + // once all the authorizations are satisfied. + FinalizeURL string + + // CertURL points to the certificate that has been issued in response to this order. + CertURL string + + // The error that occurred while processing the order as received from a CA, if any. + Error *Error +} + +// OrderOption allows customizing Client.AuthorizeOrder call. +type OrderOption interface { + privateOrderOpt() +} + +// WithOrderNotBefore sets order's NotBefore field. +func WithOrderNotBefore(t time.Time) OrderOption { + return orderNotBeforeOpt(t) +} + +// WithOrderNotAfter sets order's NotAfter field. +func WithOrderNotAfter(t time.Time) OrderOption { + return orderNotAfterOpt(t) } +type orderNotBeforeOpt time.Time + +func (orderNotBeforeOpt) privateOrderOpt() {} + +type orderNotAfterOpt time.Time + +func (orderNotAfterOpt) privateOrderOpt() {} + // Authorization encodes an authorization response. type Authorization struct { // URI uniquely identifies a authorization. @@ -271,19 +344,42 @@ type Authorization struct { // AuthzID is an identifier that an account is authorized to represent. type AuthzID struct { - Type string // The type of identifier, e.g. "dns". + Type string // The type of identifier, "dns" or "ip". Value string // The identifier itself, e.g. "example.org". } +// DomainIDs creates a slice of AuthzID with "dns" identifier type. +func DomainIDs(names ...string) []AuthzID { + a := make([]AuthzID, len(names)) + for i, v := range names { + a[i] = AuthzID{Type: "dns", Value: v} + } + return a +} + +// IPIDs creates a slice of AuthzID with "ip" identifier type. +// Each element of addr is textual form of an address as defined +// in RFC1123 Section 2.1 for IPv4 and in RFC5952 Section 4 for IPv6. +func IPIDs(addr ...string) []AuthzID { + a := make([]AuthzID, len(addr)) + for i, v := range addr { + a[i] = AuthzID{Type: "ip", Value: v} + } + return a +} + +// wireAuthzID is ACME JSON representation of authorization identifier objects. +type wireAuthzID struct { + Type string `json:"type"` + Value string `json:"value"` +} + // wireAuthz is ACME JSON representation of Authorization objects. type wireAuthz struct { Status string Challenges []wireChallenge Combinations [][]int - Identifier struct { - Type string - Value string - } + Identifier wireAuthzID } func (z *wireAuthz) authorization(uri string) *Authorization { @@ -313,6 +409,28 @@ func (z *wireAuthz) error(uri string) *AuthorizationError { return err } +// Challenge encodes a returned CA challenge. +// Its Error field may be non-nil if the challenge is part of an Authorization +// with StatusInvalid. +type Challenge struct { + // Type is the challenge type, e.g. "http-01", "tls-sni-02", "dns-01". + Type string + + // URI is where a challenge response can be posted to. + URI string + + // Token is a random value that uniquely identifies the challenge. + Token string + + // Status identifies the status of this challenge. + Status string + + // Error indicates the reason for an authorization failure + // when this challenge was used. + // The type of a non-nil value is *Error. + Error error +} + // wireChallenge is ACME JSON challenge representation. type wireChallenge struct { URI string `json:"uri"`