diff --git a/.gitignore b/.gitignore index 7cba0d088..958578f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ coverage.txt vendor output .idea + +# vscode +*.code-workspace +*_bin diff --git a/acme/api/handler.go b/acme/api/handler.go index e848e64d8..c7f05d89d 100644 --- a/acme/api/handler.go +++ b/acme/api/handler.go @@ -106,26 +106,57 @@ func (h *Handler) GetAuthz(w http.ResponseWriter, r *http.Request) { api.JSON(w, authz) } -// GetChallenge ACME api for retrieving a Challenge. +// GetChallenge is the ACME api for retrieving a Challenge resource. +// +// Potential Challenges are requested by the client when creating an order. +// Once the client knows the appropriate validation resources are provisioned, +// it makes a POST-as-GET request to this endpoint in order to initiate the +// validation flow. +// +// The validation state machine describes the flow for a challenge. +// +// https://tools.ietf.org/html/rfc8555#section-7.1.6 +// +// Once a validation attempt has completed without error, the challenge's +// status is updated depending on the result (valid|invalid) of the server's +// validation attempt. Once this is the case, a challenge cannot be reset. +// +// If a challenge cannot be completed because no suitable data can be +// acquired the server (whilst communicating retry information) and the +// client (whilst respecting the information from the server) may request +// retries of the validation. +// +// https://tools.ietf.org/html/rfc8555#section-8.2 +// +// Retry status is communicated using the error field and by sending a +// Retry-After header back to the client. +// +// The request body is challenge-specific. The current challenges (http-01, +// dns-01, tls-alpn-01) simply expect an empty object ("{}") in the payload +// of the JWT sent by the client. We don't gain anything by stricly enforcing +// nonexistence of unknown attributes, or, in these three cases, enforcing +// an empty payload. And the spec also says to just ignore it: +// +// > The server MUST ignore any fields in the response object +// > that are not specified as response fields for this type of challenge. +// +// https://tools.ietf.org/html/rfc8555#section-7.5.1 +// func (h *Handler) GetChallenge(w http.ResponseWriter, r *http.Request) { acc, err := acme.AccountFromContext(r.Context()) if err != nil { api.WriteError(w, err) return } - // Just verify that the payload was set, since we're not strictly adhering - // to ACME V2 spec for reasons specified below. + + // Just verify that the payload was set since the client is required + // to send _something_. _, err = payloadFromContext(r.Context()) if err != nil { api.WriteError(w, err) return } - // NOTE: We should be checking that the request is either a POST-as-GET, or - // that the payload is an empty JSON block ({}). However, older ACME clients - // still send a vestigial body (rather than an empty JSON block) and - // strict enforcement would render these clients broken. For the time being - // we'll just ignore the body. var ( ch *acme.Challenge chID = chi.URLParam(r, "chID") @@ -138,6 +169,13 @@ func (h *Handler) GetChallenge(w http.ResponseWriter, r *http.Request) { w.Header().Add("Link", link(h.Auth.GetLink(r.Context(), acme.AuthzLink, true, ch.GetAuthzID()), "up")) w.Header().Set("Location", h.Auth.GetLink(r.Context(), acme.ChallengeLink, true, ch.GetID())) + + if ch.Status == acme.StatusProcessing { + w.Header().Add("Retry-After", ch.RetryAfter) + // 200s are cachable. Don't cache this because it will likely change. + w.Header().Add("Cache-Control", "no-cache") + } + api.JSON(w, ch) } diff --git a/acme/api/handler_test.go b/acme/api/handler_test.go index 9674b035c..3bf73a0e6 100644 --- a/acme/api/handler_test.go +++ b/acme/api/handler_test.go @@ -244,7 +244,7 @@ func TestHandlerGetNonce(t *testing.T) { } func TestHandlerGetDirectory(t *testing.T) { - auth, err := acme.NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil) + auth, err := acme.NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) prov := newProv() @@ -599,6 +599,7 @@ func TestHandlerGetChallenge(t *testing.T) { ch acme.Challenge problem *acme.Error } + var tests = map[string]func(t *testing.T) test{ "fail/no-account": func(t *testing.T) test { return test{ @@ -607,6 +608,7 @@ func TestHandlerGetChallenge(t *testing.T) { problem: acme.AccountDoesNotExistErr(nil), } }, + "fail/nil-account": func(t *testing.T) test { ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) ctx = context.WithValue(ctx, acme.AccContextKey, nil) @@ -616,6 +618,7 @@ func TestHandlerGetChallenge(t *testing.T) { problem: acme.AccountDoesNotExistErr(nil), } }, + "fail/no-payload": func(t *testing.T) test { acc := &acme.Account{ID: "accID"} ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) @@ -626,6 +629,7 @@ func TestHandlerGetChallenge(t *testing.T) { problem: acme.ServerInternalErr(errors.New("payload expected in request context")), } }, + "fail/nil-payload": func(t *testing.T) test { acc := &acme.Account{ID: "accID"} ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) @@ -637,6 +641,7 @@ func TestHandlerGetChallenge(t *testing.T) { problem: acme.ServerInternalErr(errors.New("payload expected in request context")), } }, + "fail/validate-challenge-error": func(t *testing.T) test { acc := &acme.Account{ID: "accID"} ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) @@ -645,13 +650,14 @@ func TestHandlerGetChallenge(t *testing.T) { ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx) return test{ auth: &mockAcmeAuthority{ - err: acme.UnauthorizedErr(nil), + err: acme.ServerInternalErr(nil), }, ctx: ctx, - statusCode: 401, - problem: acme.UnauthorizedErr(nil), + statusCode: 500, + problem: acme.ServerInternalErr(nil), } }, + "fail/get-challenge-error": func(t *testing.T) test { acc := &acme.Account{ID: "accID"} ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) @@ -667,6 +673,7 @@ func TestHandlerGetChallenge(t *testing.T) { problem: acme.UnauthorizedErr(nil), } }, + "ok/validate-challenge": func(t *testing.T) test { key, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) @@ -714,7 +721,60 @@ func TestHandlerGetChallenge(t *testing.T) { ch: ch, } }, + + "ok/retry-after": func(t *testing.T) test { + key, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + assert.FatalError(t, err) + acc := &acme.Account{ID: "accID", Key: key} + ctx := context.WithValue(context.Background(), acme.ProvisionerContextKey, prov) + ctx = context.WithValue(ctx, acme.AccContextKey, acc) + ctx = context.WithValue(ctx, acme.PayloadContextKey, &payloadInfo{isEmptyJSON: true}) + ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx) + ctx = context.WithValue(ctx, acme.BaseURLContextKey, baseURL) + ch := ch() + ch.Status = "processing" + ch.RetryAfter = time.Now().Add(1 * time.Minute).UTC().Format(time.RFC3339) + chJSON, err := json.Marshal(ch) + assert.FatalError(t, err) + ctx = context.WithValue(ctx, acme.PayloadContextKey, &payloadInfo{value: chJSON}) + count := 0 + return test{ + auth: &mockAcmeAuthority{ + validateChallenge: func(ctx context.Context, accID, id string, jwk *jose.JSONWebKey) (*acme.Challenge, error) { + p, err := acme.ProvisionerFromContext(ctx) + assert.FatalError(t, err) + assert.Equals(t, p, prov) + assert.Equals(t, accID, acc.ID) + assert.Equals(t, id, ch.ID) + assert.Equals(t, jwk.KeyID, key.KeyID) + return &ch, nil + }, + getLink: func(ctx context.Context, typ acme.Link, abs bool, in ...string) string { + var ret string + switch count { + case 0: + assert.Equals(t, typ, acme.AuthzLink) + assert.True(t, abs) + assert.Equals(t, in, []string{ch.AuthzID}) + ret = fmt.Sprintf("%s/acme/%s/authz/%s", baseURL.String(), provName, ch.AuthzID) + case 1: + assert.Equals(t, typ, acme.ChallengeLink) + assert.True(t, abs) + assert.Equals(t, in, []string{ch.ID}) + ret = url + } + count++ + return ret + }, + }, + ctx: ctx, + statusCode: 200, + ch: ch, + } + }, } + + // Run the tests for name, run := range tests { tc := run(t) t.Run(name, func(t *testing.T) { @@ -741,13 +801,22 @@ func TestHandlerGetChallenge(t *testing.T) { assert.Equals(t, ae.Identifier, prob.Identifier) assert.Equals(t, ae.Subproblems, prob.Subproblems) assert.Equals(t, res.Header["Content-Type"], []string{"application/problem+json"}) - } else { + } else if res.StatusCode >= 200 { expB, err := json.Marshal(tc.ch) assert.FatalError(t, err) assert.Equals(t, bytes.TrimSpace(body), expB) assert.Equals(t, res.Header["Link"], []string{fmt.Sprintf("<%s/acme/%s/authz/%s>;rel=\"up\"", baseURL, provName, tc.ch.AuthzID)}) assert.Equals(t, res.Header["Location"], []string{url}) assert.Equals(t, res.Header["Content-Type"], []string{"application/json"}) + switch tc.ch.Status { + case "processing": + assert.Equals(t, res.Header["Cache-Control"], []string{"no-cache"}) + assert.Equals(t, res.Header["Retry-After"], []string{tc.ch.RetryAfter}) + case "valid", "invalid": + // + } + } else { + assert.Fatal(t, false, "Unexpected Status Code") } }) } diff --git a/acme/authority.go b/acme/authority.go index eaefca55a..7f8cccac8 100644 --- a/acme/authority.go +++ b/acme/authority.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "log" "net" "net/http" "net/url" @@ -50,6 +51,7 @@ type Authority struct { db nosql.DB dir *directory signAuth SignAuthority + ordinal int } var ( @@ -64,7 +66,7 @@ var ( ) // NewAuthority returns a new Authority that implements the ACME interface. -func NewAuthority(db nosql.DB, dns, prefix string, signAuth SignAuthority) (*Authority, error) { +func NewAuthority(db nosql.DB, dns, prefix string, signAuth SignAuthority, ordinal int) (*Authority, error) { if _, ok := db.(*database.SimpleDB); !ok { // If it's not a SimpleDB then go ahead and bootstrap the DB with the // necessary ACME tables. SimpleDB should ONLY be used for testing. @@ -79,7 +81,7 @@ func NewAuthority(db nosql.DB, dns, prefix string, signAuth SignAuthority) (*Aut } } return &Authority{ - db: db, dir: newDirectory(dns, prefix), signAuth: signAuth, + db: db, dir: newDirectory(dns, prefix), signAuth: signAuth, ordinal: ordinal, }, nil } @@ -269,32 +271,225 @@ func (a *Authority) GetAuthz(ctx context.Context, accID, authzID string) (*Authz return az.toACME(ctx, a.db, a.dir) } -// ValidateChallenge attempts to validate the challenge. +// ValidateChallenge loads a challenge resource and then begins the validation process if the challenge +// is not in one of its terminal states {valid|invalid}. +// +// The challenge validation state machine looks like: +// +// * https://tools.ietf.org/html/rfc8555#section-7.1.6 +// +// While in the processing state, the server may retry as it sees fit. The challenge validation strategy +// needs to be rather specific in order for retries to work in a replicated, crash-proof deployment. +// In general, the goal is to allow requests to hit arbitrary instances of step-ca while managing retry +// responsibility such that multiple instances agree on an owner. Additionally, when a deployment of the +// CA is in progress, the ownership should be carried forward and new, updated (or in general, restarted), +// instances should pick back up where the crashed instance left off. +// +// The steps are: +// +// 1. Upon incoming request to the challenge endpoint, take ownership of the retry responsibility. +// (a) Set Retry.Owner to this instance's ordinal (STEP_CA_ORDINAL). +// (b) Set Retry.NumAttempts to 0 and Retry.MaxAttempts to the desired max. +// (c) Set Challenge.Status to "processing" +// (d) Set retry_after to a time (retryInterval) in the future. +// 2. Perform the validation attempt. +// 3. If the validation attempt results in a challenge that is still processing, schedule a retry. +// +// It's possible that another request to re-attempt the challenge comes in while a retry attempt is +// pending from a previous request. In general, these old attempts will see that Retry.NextAttempt +// is in the future and drop their task. Because another instance may have taken ownership, old attempts +// would also see a different ordinal than their own. +// +// 4. When the retry timer fires, check to make sure the retry should still process. +// (a) Refresh the challenge from the DB. +// (a) Check that Retry.Owner is equal to this instance's ordinal. +// (b) Check that Retry.NextAttempt is in the past. +// 5. If the retry will commence, immediately update Retry.NextAttempt and save the challenge. +// +// Finally, if this instance is terminated, retries need to be reschedule when the instance restarts. This +// is handled in the acme provisioner (authority/provisioner/acme.go) initialization. +// +// Note: the default ordinal does not need to be changed unless step-ca is running in a replicated scenario. +// func (a *Authority) ValidateChallenge(ctx context.Context, accID, chID string, jwk *jose.JSONWebKey) (*Challenge, error) { ch, err := getChallenge(a.db, chID) if err != nil { return nil, err } + switch ch.getStatus() { + case StatusPending, StatusProcessing: + break + case StatusInvalid, StatusValid: + return ch.toACME(ctx, a.dir) + default: + e := errors.Errorf("unknown challenge state: %s", ch.getStatus()) + return nil, ServerInternalErr(e) + } + + // Validate the challenge belongs to the account owned by the requester. if accID != ch.getAccountID() { return nil, UnauthorizedErr(errors.New("account does not own challenge")) } + + p, err := ProvisionerFromContext(ctx) + if err != nil { + return nil, err + } + + // Take ownership of the challenge status and retry state. The values must be reset. + up := ch.clone() + up.Status = StatusProcessing + up.Retry = &Retry{ + Owner: a.ordinal, + ProvisionerID: p.GetID(), + NumAttempts: 0, + MaxAttempts: 10, + NextAttempt: time.Now().Add(retryInterval).UTC().Format(time.RFC3339), + } + err = up.save(a.db, ch) + if err != nil { + return nil, Wrap(err, "error saving challenge") + } + ch = up + + v, err := a.validate(ch, jwk) + // An error here is non-recoverable. Recoverable errors are set on the challenge object + // and should not be returned directly. + if err != nil { + return nil, Wrap(err, "error attempting challenge validation") + } + err = v.save(a.db, ch) + if err != nil { + return nil, Wrap(err, "error saving challenge") + } + ch = v + + switch ch.getStatus() { + case StatusValid, StatusInvalid: + break + case StatusProcessing: + if ch.getRetry().Active() { + time.AfterFunc(retryInterval, func() { + a.RetryChallenge(ch.getID()) + }) + } + default: + e := errors.Errorf("post-validation challenge in unexpected state, %s", ch.getStatus()) + return nil, ServerInternalErr(e) + } + return ch.toACME(ctx, a.dir) +} + +// The challenge validation process is specific to the type of challenge (dns-01, http-01, tls-alpn-01). +// But, we still pass generic "options" to the polymorphic validate call. +func (a *Authority) validate(ch challenge, jwk *jose.JSONWebKey) (challenge, error) { client := http.Client{ Timeout: time.Duration(30 * time.Second), } dialer := &net.Dialer{ Timeout: 30 * time.Second, } - ch, err = ch.validate(a.db, jwk, validateOptions{ + return ch.clone().morph().validate(jwk, validateOptions{ httpGet: client.Get, lookupTxt: net.LookupTXT, tlsDial: func(network, addr string, config *tls.Config) (*tls.Conn, error) { return tls.DialWithDialer(dialer, network, addr, config) }, }) +} + +const retryInterval = 12 * time.Second + +// RetryChallenge behaves similar to ValidateChallenge, but simply attempts to perform a validation and +// write update the challenge record in the db if the challenge has remaining retry attempts. +// +// see: ValidateChallenge +func (a *Authority) RetryChallenge(chID string) { + ch, err := getChallenge(a.db, chID) if err != nil { - return nil, Wrap(err, "error attempting challenge validation") + return + } + switch ch.getStatus() { + case StatusPending: + e := errors.New("pending challenges must first be moved to the processing state") + log.Printf("%v", e) + return + case StatusInvalid, StatusValid: + return + case StatusProcessing: + break + default: + e := errors.Errorf("unknown challenge state: %s", ch.getStatus()) + log.Printf("%v", e) + return + } + + // When retrying, check to make sure the ordinal has not changed. + // Make sure there are still retries left. + // Then check to make sure Retry.NextAttempt is in the past. + retry := ch.getRetry() + switch { + case retry.Owner != a.ordinal: + return + case !retry.Active(): + return + } + t, err := time.Parse(time.RFC3339, retry.NextAttempt) + now := time.Now().UTC() + switch { + case err != nil: + return + case t.Before(now): + return + } + + // Update the db so that other retries simply drop when their timer fires. + up := ch.clone() + up.Retry.NextAttempt = now.Add(retryInterval).UTC().Format(time.RFC3339) + up.Retry.NumAttempts++ + err = up.save(a.db, ch) + if err != nil { + return + } + ch = up + + p, err := a.LoadProvisionerByID(retry.ProvisionerID) + if err != nil { + return + } + if p.GetType() != provisioner.TypeACME { + log.Printf("%v", AccountDoesNotExistErr(errors.New("provisioner must be of type ACME"))) + return + } + ctx := context.WithValue(context.Background(), ProvisionerContextKey, p) + acc, err := a.GetAccount(ctx, ch.getAccountID()) + if err != nil { + return + } + + v, err := a.validate(ch, acc.Key) + if err != nil { + return + } + err = v.save(a.db, ch) + if err != nil { + return + } + ch = v + + switch ch.getStatus() { + case StatusValid, StatusInvalid: + break + case StatusProcessing: + if ch.getRetry().Active() { + time.AfterFunc(retryInterval, func() { + a.RetryChallenge(ch.getID()) + }) + } + default: + e := errors.Errorf("post-validation challenge in unexpected state, %s", ch.getStatus()) + log.Printf("%v", e) } - return ch.toACME(ctx, a.db, a.dir) } // GetCertificate retrieves the Certificate by ID. diff --git a/acme/authority_test.go b/acme/authority_test.go index aec022a3d..e1210acb1 100644 --- a/acme/authority_test.go +++ b/acme/authority_test.go @@ -16,7 +16,7 @@ import ( ) func TestAuthorityGetLink(t *testing.T) { - auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil) + auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) prov := newProv() provName := url.PathEscape(prov.GetName()) @@ -76,7 +76,7 @@ func TestAuthorityGetLink(t *testing.T) { } func TestAuthorityGetDirectory(t *testing.T) { - auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil) + auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) prov := newProv() @@ -154,7 +154,7 @@ func TestAuthorityNewNonce(t *testing.T) { MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -170,7 +170,7 @@ func TestAuthorityNewNonce(t *testing.T) { *res = string(key) return nil, true, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -209,7 +209,7 @@ func TestAuthorityUseNonce(t *testing.T) { MUpdate: func(tx *database.Tx) error { return errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -221,7 +221,7 @@ func TestAuthorityUseNonce(t *testing.T) { MUpdate: func(tx *database.Tx) error { return nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -267,7 +267,7 @@ func TestAuthorityNewAccount(t *testing.T) { MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -293,7 +293,7 @@ func TestAuthorityNewAccount(t *testing.T) { count++ return nil, true, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -345,7 +345,7 @@ func TestAuthorityGetAccount(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -362,7 +362,7 @@ func TestAuthorityGetAccount(t *testing.T) { MGet: func(bucket, key []byte) ([]byte, error) { return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -414,7 +414,7 @@ func TestAuthorityGetAccountByKey(t *testing.T) { jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) jwk.Key = "foo" - auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil) + auth, err := NewAuthority(new(db.MockNoSQLDB), "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -433,7 +433,7 @@ func TestAuthorityGetAccountByKey(t *testing.T) { assert.Equals(t, key, []byte(kid)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -465,7 +465,7 @@ func TestAuthorityGetAccountByKey(t *testing.T) { count++ return ret, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -521,7 +521,7 @@ func TestAuthorityGetOrder(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -540,7 +540,7 @@ func TestAuthorityGetOrder(t *testing.T) { assert.Equals(t, key, []byte(o.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -569,7 +569,7 @@ func TestAuthorityGetOrder(t *testing.T) { return nil, ServerInternalErr(errors.New("force")) } }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -590,7 +590,7 @@ func TestAuthorityGetOrder(t *testing.T) { assert.Equals(t, key, []byte(o.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -644,7 +644,7 @@ func TestAuthorityGetCertificate(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -663,7 +663,7 @@ func TestAuthorityGetCertificate(t *testing.T) { assert.Equals(t, key, []byte(cert.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -683,7 +683,7 @@ func TestAuthorityGetCertificate(t *testing.T) { assert.Equals(t, key, []byte(cert.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -740,7 +740,7 @@ func TestAuthorityGetAuthz(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -759,7 +759,7 @@ func TestAuthorityGetAuthz(t *testing.T) { assert.Equals(t, key, []byte(az.getID())) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -790,7 +790,7 @@ func TestAuthorityGetAuthz(t *testing.T) { count++ return ret, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -882,7 +882,7 @@ func TestAuthorityGetAuthz(t *testing.T) { count++ return ret, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -934,7 +934,7 @@ func TestAuthorityNewOrder(t *testing.T) { MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -988,7 +988,7 @@ func TestAuthorityNewOrder(t *testing.T) { MGet: func(bucket, key []byte) ([]byte, error) { return nil, database.ErrNotFound }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1042,7 +1042,7 @@ func TestAuthorityGetOrdersByAccount(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1074,7 +1074,7 @@ func TestAuthorityGetOrdersByAccount(t *testing.T) { count++ return ret, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1121,7 +1121,7 @@ func TestAuthorityGetOrdersByAccount(t *testing.T) { count++ return ret, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1172,7 +1172,7 @@ func TestAuthorityFinalizeOrder(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1191,7 +1191,7 @@ func TestAuthorityFinalizeOrder(t *testing.T) { assert.Equals(t, key, []byte(o.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1217,7 +1217,7 @@ func TestAuthorityFinalizeOrder(t *testing.T) { assert.Equals(t, key, []byte(o.ID)) return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1239,7 +1239,7 @@ func TestAuthorityFinalizeOrder(t *testing.T) { assert.Equals(t, key, []byte(o.ID)) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1296,7 +1296,7 @@ func TestAuthorityValidateChallenge(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1304,6 +1304,7 @@ func TestAuthorityValidateChallenge(t *testing.T) { err: ServerInternalErr(errors.Errorf("error loading challenge %s: force", id)), } }, + "fail/challenge-not-owned-by-account": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) @@ -1315,7 +1316,7 @@ func TestAuthorityValidateChallenge(t *testing.T) { assert.Equals(t, key, []byte(ch.getID())) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1324,6 +1325,7 @@ func TestAuthorityValidateChallenge(t *testing.T) { err: UnauthorizedErr(errors.New("account does not own challenge")), } }, + "fail/validate-error": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) @@ -1340,23 +1342,25 @@ func TestAuthorityValidateChallenge(t *testing.T) { assert.Equals(t, key, []byte(ch.getID())) return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, id: ch.getID(), accID: ch.getAccountID(), - err: ServerInternalErr(errors.New("error attempting challenge validation: error saving acme challenge: force")), + err: ServerInternalErr(errors.New("error saving challenge: error saving acme challenge: force")), } }, - "ok": func(t *testing.T) test { + + "ok/already-valid": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - _ch, ok := ch.(*http01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusValid - _ch.baseChallenge.Validated = clock.Now() - b, err := json.Marshal(ch) + bc := ch.clone() + bc.Status = StatusValid + bc.Validated = clock.Now() + bc.Retry = nil + rch := bc.morph() + b, err := json.Marshal(rch) assert.FatalError(t, err) auth, err := NewAuthority(&db.MockNoSQLDB{ MGet: func(bucket, key []byte) ([]byte, error) { @@ -1364,16 +1368,17 @@ func TestAuthorityValidateChallenge(t *testing.T) { assert.Equals(t, key, []byte(ch.getID())) return b, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, id: ch.getID(), accID: ch.getAccountID(), - ch: ch, + ch: rch, } }, } + for name, run := range tests { t.Run(name, func(t *testing.T) { tc := run(t) @@ -1389,12 +1394,10 @@ func TestAuthorityValidateChallenge(t *testing.T) { if assert.Nil(t, tc.err) { gotb, err := json.Marshal(acmeCh) assert.FatalError(t, err) - - acmeExp, err := tc.ch.toACME(ctx, nil, tc.auth.dir) + acmeExp, err := tc.ch.toACME(ctx, tc.auth.dir) assert.FatalError(t, err) expb, err := json.Marshal(acmeExp) assert.FatalError(t, err) - assert.Equals(t, expb, gotb) } } @@ -1423,7 +1426,7 @@ func TestAuthorityUpdateAccount(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1445,7 +1448,7 @@ func TestAuthorityUpdateAccount(t *testing.T) { MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1473,7 +1476,7 @@ func TestAuthorityUpdateAccount(t *testing.T) { assert.Equals(t, key, []byte(acc.ID)) return nil, true, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1530,7 +1533,7 @@ func TestAuthorityDeactivateAccount(t *testing.T) { assert.Equals(t, key, []byte(id)) return nil, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1551,7 +1554,7 @@ func TestAuthorityDeactivateAccount(t *testing.T) { MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { return nil, false, errors.New("force") }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, @@ -1579,7 +1582,7 @@ func TestAuthorityDeactivateAccount(t *testing.T) { assert.Equals(t, key, []byte(acc.ID)) return nil, true, nil }, - }, "ca.smallstep.com", "acme", nil) + }, "ca.smallstep.com", "acme", nil, 0) assert.FatalError(t, err) return test{ auth: auth, diff --git a/acme/authz.go b/acme/authz.go index 8c45bce08..1a118bcc1 100644 --- a/acme/authz.go +++ b/acme/authz.go @@ -148,7 +148,7 @@ func (ba *baseAuthz) toACME(ctx context.Context, db nosql.DB, dir *directory) (* if err != nil { return nil, err } - chs[i], err = ch.toACME(ctx, db, dir) + chs[i], err = ch.toACME(ctx, dir) if err != nil { return nil, err } diff --git a/acme/authz_test.go b/acme/authz_test.go index 31e6bb58f..6fa24f256 100644 --- a/acme/authz_test.go +++ b/acme/authz_test.go @@ -438,9 +438,9 @@ func TestAuthzToACME(t *testing.T) { assert.Equals(t, acmeAz.Identifier, iden) assert.Equals(t, acmeAz.Status, StatusPending) - acmeCh1, err := ch1.toACME(ctx, nil, dir) + acmeCh1, err := ch1.toACME(ctx, dir) assert.FatalError(t, err) - acmeCh2, err := ch2.toACME(ctx, nil, dir) + acmeCh2, err := ch2.toACME(ctx, dir) assert.FatalError(t, err) assert.Equals(t, acmeAz.Challenges[0], acmeCh1) diff --git a/acme/challenge.go b/acme/challenge.go index 82fa93278..32801ab26 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -25,14 +25,15 @@ import ( // Challenge is a subset of the challenge type containing only those attributes // required for responses in the ACME protocol. type Challenge struct { - Type string `json:"type"` - Status string `json:"status"` - Token string `json:"token"` - Validated string `json:"validated,omitempty"` - URL string `json:"url"` - Error *AError `json:"error,omitempty"` - ID string `json:"-"` - AuthzID string `json:"-"` + Type string `json:"type"` + Status string `json:"status"` + Token string `json:"token"` + Validated string `json:"validated,omitempty"` + URL string `json:"url"` + Error *AError `json:"error,omitempty"` + RetryAfter string `json:"retry_after,omitempty"` + ID string `json:"-"` + AuthzID string `json:"-"` } // ToLog enables response logging. @@ -67,7 +68,7 @@ type validateOptions struct { // challenge is the interface ACME challenege types must implement. type challenge interface { save(db nosql.DB, swap challenge) error - validate(nosql.DB, *jose.JSONWebKey, validateOptions) (challenge, error) + validate(*jose.JSONWebKey, validateOptions) (challenge, error) getType() string getError() *AError getValue() string @@ -75,18 +76,20 @@ type challenge interface { getID() string getAuthzID() string getToken() string + getRetry() *Retry clone() *baseChallenge getAccountID() string getValidated() time.Time getCreated() time.Time - toACME(context.Context, nosql.DB, *directory) (*Challenge, error) + toACME(context.Context, *directory) (*Challenge, error) } // ChallengeOptions is the type used to created a new Challenge. type ChallengeOptions struct { - AccountID string - AuthzID string - Identifier Identifier + AccountID string + AuthzID string + ProvisionerID string + Identifier Identifier } // baseChallenge is the base Challenge type that others build from. @@ -98,9 +101,10 @@ type baseChallenge struct { Status string `json:"status"` Token string `json:"token"` Value string `json:"value"` - Validated time.Time `json:"validated"` Created time.Time `json:"created"` + Validated time.Time `json:"validated"` Error *AError `json:"error"` + Retry *Retry `json:"retry"` } func newBaseChallenge(accountID, authzID string) (*baseChallenge, error) { @@ -158,6 +162,11 @@ func (bc *baseChallenge) getToken() string { return bc.Token } +// getRetry returns the retry state of the baseChallenge +func (bc *baseChallenge) getRetry() *Retry { + return bc.Retry +} + // getValidated returns the validated time of the baseChallenge. func (bc *baseChallenge) getValidated() time.Time { return bc.Validated @@ -175,7 +184,7 @@ func (bc *baseChallenge) getError() *AError { // toACME converts the internal Challenge type into the public acmeChallenge // type for presentation in the ACME protocol. -func (bc *baseChallenge) toACME(ctx context.Context, db nosql.DB, dir *directory) (*Challenge, error) { +func (bc *baseChallenge) toACME(ctx context.Context, dir *directory) (*Challenge, error) { ac := &Challenge{ Type: bc.getType(), Status: bc.getStatus(), @@ -190,6 +199,9 @@ func (bc *baseChallenge) toACME(ctx context.Context, db nosql.DB, dir *directory if bc.Error != nil { ac.Error = bc.Error } + if bc.Retry != nil && bc.Status == StatusProcessing { + ac.RetryAfter = bc.Retry.NextAttempt + } return ac, nil } @@ -228,19 +240,17 @@ func (bc *baseChallenge) save(db nosql.DB, old challenge) error { func (bc *baseChallenge) clone() *baseChallenge { u := *bc + if bc.Retry != nil { + r := *bc.Retry + u.Retry = &r + } return &u } -func (bc *baseChallenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { +func (bc *baseChallenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { return nil, ServerInternalErr(errors.New("unimplemented")) } -func (bc *baseChallenge) storeError(db nosql.DB, err *Error) error { - clone := bc.clone() - clone.Error = err.ToACME() - return clone.save(db, bc) -} - // unmarshalChallenge unmarshals a challenge type into the correct sub-type. func unmarshalChallenge(data []byte) (challenge, error) { var getType struct { @@ -277,6 +287,34 @@ func unmarshalChallenge(data []byte) (challenge, error) { } } +func (bc *baseChallenge) morph() challenge { + switch bc.getType() { + case "dns-01": + return &dns01Challenge{bc} + case "http-01": + return &http01Challenge{bc} + case "tls-alpn-01": + return &tlsALPN01Challenge{bc} + default: + return bc + } +} + +// Retry information for challenges is internally relevant and needs to be stored in the DB, but should not be part +// of the public challenge API apart from the Retry-After header. +type Retry struct { + Owner int `json:"owner"` + ProvisionerID string `json:"provisionerid"` + NumAttempts int `json:"numattempts"` + MaxAttempts int `json:"maxattempts"` + NextAttempt string `json:"nextattempt"` +} + +// Active returns a boolean indicating whether a Retry struct has remaining attempts or not. +func (r *Retry) Active() bool { + return r.NumAttempts < r.MaxAttempts +} + // http01Challenge represents an http-01 acme challenge. type http01Challenge struct { *baseChallenge @@ -301,61 +339,66 @@ func newHTTP01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) { // Validate attempts to validate the challenge. If the challenge has been // satisfactorily validated, the 'status' and 'validated' attributes are // updated. -func (hc *http01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { +func (hc *http01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { // If already valid or invalid then return without performing validation. - if hc.getStatus() == StatusValid || hc.getStatus() == StatusInvalid { + switch hc.getStatus() { + case StatusPending: + e := errors.New("pending challenges must first be moved to the processing state") + return nil, ServerInternalErr(e) + case StatusProcessing: + break + case StatusValid, StatusInvalid: return hc, nil + default: + e := errors.Errorf("unknown challenge state: %s", hc.getStatus()) + return nil, ServerInternalErr(e) } - url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", hc.Value, hc.Token) + up := &http01Challenge{hc.baseChallenge.clone()} + + url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", hc.Value, hc.Token) resp, err := vo.httpGet(url) if err != nil { - if err = hc.storeError(db, ConnectionErr(errors.Wrapf(err, - "error doing http GET for url %s", url))); err != nil { - return nil, err - } - return hc, nil + e := errors.Wrapf(err, "error doing http GET for url %s", url) + up.Error = ConnectionErr(e).ToACME() + return up, nil } + defer resp.Body.Close() + if resp.StatusCode >= 400 { - if err = hc.storeError(db, - ConnectionErr(errors.Errorf("error doing http GET for url %s with status code %d", - url, resp.StatusCode))); err != nil { - return nil, err - } - return hc, nil + e := errors.Errorf("error doing http GET for url %s with status code %d", url, resp.StatusCode) + up.Error = ConnectionErr(e).ToACME() + return up, nil } - defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - return nil, ServerInternalErr(errors.Wrapf(err, "error reading "+ - "response body for url %s", url)) + e := errors.Wrapf(err, "error reading response body for url %s", url) + up.Error = ServerInternalErr(e).ToACME() + return up, nil } - keyAuth := strings.Trim(string(body), "\r\n") + keyAuth := strings.Trim(string(body), "\r\n") expected, err := KeyAuthorization(hc.Token, jwk) if err != nil { return nil, err } - if keyAuth != expected { - if err = hc.storeError(db, - RejectedIdentifierErr(errors.Errorf("keyAuthorization does not match; "+ - "expected %s, but got %s", expected, keyAuth))); err != nil { - return nil, err - } - return hc, nil - } - - // Update and store the challenge. - upd := &http01Challenge{hc.baseChallenge.clone()} - upd.Status = StatusValid - upd.Error = nil - upd.Validated = clock.Now() - if err := upd.save(db, hc); err != nil { - return nil, err + // success + if keyAuth == expected { + up.Validated = clock.Now() + up.Status = StatusValid + up.Error = nil + up.Retry = nil + return up, nil } - return upd, nil + + // fail + up.Status = StatusInvalid + e := errors.Errorf("keyAuthorization does not match; expected %s, but got %s", expected, keyAuth) + up.Error = IncorrectResponseErr(e).ToACME() + up.Retry = nil + return up, nil } type tlsALPN01Challenge struct { @@ -371,34 +414,41 @@ func newTLSALPN01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) bc.Type = "tls-alpn-01" bc.Value = ops.Identifier.Value - hc := &tlsALPN01Challenge{bc} - if err := hc.save(db, nil); err != nil { + tc := &tlsALPN01Challenge{bc} + if err := tc.save(db, nil); err != nil { return nil, err } - return hc, nil + return tc, nil } -func (tc *tlsALPN01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { +func (tc *tlsALPN01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { // If already valid or invalid then return without performing validation. - if tc.getStatus() == StatusValid || tc.getStatus() == StatusInvalid { + switch tc.getStatus() { + case StatusPending: + e := errors.New("pending challenges must first be moved to the processing state") + return nil, ServerInternalErr(e) + case StatusProcessing: + break + case StatusValid, StatusInvalid: return tc, nil + default: + e := errors.Errorf("unknown challenge state: %s", tc.getStatus()) + return nil, ServerInternalErr(e) } + up := &tlsALPN01Challenge{tc.baseChallenge.clone()} + config := &tls.Config{ NextProtos: []string{"acme-tls/1"}, ServerName: tc.Value, InsecureSkipVerify: true, // we expect a self-signed challenge certificate } - hostPort := net.JoinHostPort(tc.Value, "443") - conn, err := vo.tlsDial("tcp", hostPort, config) if err != nil { - if err = tc.storeError(db, - ConnectionErr(errors.Wrapf(err, "error doing TLS dial for %s", hostPort))); err != nil { - return nil, err - } - return tc, nil + e := errors.Wrapf(err, "error doing TLS dial for %s", hostPort) + up.Error = ConnectionErr(e).ToACME() + return up, nil } defer conn.Close() @@ -406,32 +456,22 @@ func (tc *tlsALPN01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo val certs := cs.PeerCertificates if len(certs) == 0 { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("%s challenge for %s resulted in no certificates", - tc.Type, tc.Value))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("%s challenge for %s resulted in no certificates", tc.Type, tc.Value) + up.Error = TLSErr(e).ToACME() + return up, nil } - if !cs.NegotiatedProtocolIsMutual || cs.NegotiatedProtocol != "acme-tls/1" { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("cannot negotiate ALPN acme-tls/1 protocol for "+ - "tls-alpn-01 challenge"))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("cannot negotiate ALPN acme-tls/1 protocol for tls-alpn-01 challenge") + up.Error = TLSErr(e).ToACME() + return up, nil } leafCert := certs[0] - if len(leafCert.DNSNames) != 1 || !strings.EqualFold(leafCert.DNSNames[0], tc.Value) { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "leaf certificate must contain a single DNS name, %v", tc.Value))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ + "leaf certificate must contain a single DNS name, %v", tc.Value) + up.Error = TLSErr(e).ToACME() + return up, nil } idPeAcmeIdentifier := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31} @@ -447,45 +487,37 @@ func (tc *tlsALPN01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo val for _, ext := range leafCert.Extensions { if idPeAcmeIdentifier.Equal(ext.Id) { if !ext.Critical { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "acmeValidationV1 extension not critical"))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " + + "acmeValidationV1 extension not critical") + up.Error = IncorrectResponseErr(e).ToACME() + return up, nil } var extValue []byte rest, err := asn1.Unmarshal(ext.Value, &extValue) if err != nil || len(rest) > 0 || len(hashedKeyAuth) != len(extValue) { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "malformed acmeValidationV1 extension value"))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " + + "malformed acmeValidationV1 extension value") + up.Error = IncorrectResponseErr(e).ToACME() + return up, nil } if subtle.ConstantTimeCompare(hashedKeyAuth[:], extValue) != 1 { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "expected acmeValidationV1 extension value %s for this challenge but got %s", - hex.EncodeToString(hashedKeyAuth[:]), hex.EncodeToString(extValue)))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ + "expected acmeValidationV1 extension value %s for this challenge but got %s", + hex.EncodeToString(hashedKeyAuth[:]), hex.EncodeToString(extValue)) + up.Error = IncorrectResponseErr(e).ToACME() + // There is an appropriate value, but it doesn't match. + up.Status = StatusInvalid + return up, nil } - upd := &tlsALPN01Challenge{tc.baseChallenge.clone()} - upd.Status = StatusValid - upd.Error = nil - upd.Validated = clock.Now() - - if err := upd.save(db, tc); err != nil { - return nil, err - } - return upd, nil + up.Validated = clock.Now() + up.Status = StatusValid + up.Error = nil + up.Retry = nil + return up, nil } if idPeAcmeIdentifierV1Obsolete.Equal(ext.Id) { @@ -494,20 +526,16 @@ func (tc *tlsALPN01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo val } if foundIDPeAcmeIdentifierV1Obsolete { - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "obsolete id-pe-acmeIdentifier in acmeValidationV1 extension"))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " + + "obsolete id-pe-acmeIdentifier in acmeValidationV1 extension") + up.Error = IncorrectResponseErr(e).ToACME() + return up, nil } - if err = tc.storeError(db, - RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ - "missing acmeValidationV1 extension"))); err != nil { - return nil, err - } - return tc, nil + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " + + "missing acmeValidationV1 extension") + up.Error = IncorrectResponseErr(e).ToACME() + return up, nil } // dns01Challenge represents an dns-01 acme challenge. @@ -531,40 +559,38 @@ func newDNS01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) { return dc, nil } -// KeyAuthorization creates the ACME key authorization value from a token -// and a jwk. -func KeyAuthorization(token string, jwk *jose.JSONWebKey) (string, error) { - thumbprint, err := jwk.Thumbprint(crypto.SHA256) - if err != nil { - return "", ServerInternalErr(errors.Wrap(err, "error generating JWK thumbprint")) - } - encPrint := base64.RawURLEncoding.EncodeToString(thumbprint) - return fmt.Sprintf("%s.%s", token, encPrint), nil -} - // validate attempts to validate the challenge. If the challenge has been // satisfactorily validated, the 'status' and 'validated' attributes are // updated. -func (dc *dns01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { +func (dc *dns01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) { // If already valid or invalid then return without performing validation. - if dc.getStatus() == StatusValid || dc.getStatus() == StatusInvalid { + switch dc.getStatus() { + case StatusPending: + e := errors.New("pending challenges must first be moved to the processing state") + return nil, ServerInternalErr(e) + case StatusProcessing: + break + case StatusValid, StatusInvalid: return dc, nil + default: + e := errors.Errorf("unknown challenge state: %s", dc.getStatus()) + return nil, ServerInternalErr(e) } + up := &dns01Challenge{dc.baseChallenge.clone()} + // Normalize domain for wildcard DNS names // This is done to avoid making TXT lookups for domains like // _acme-challenge.*.example.com // Instead perform txt lookup for _acme-challenge.example.com domain := strings.TrimPrefix(dc.Value, "*.") + record := "_acme-challenge." + domain - txtRecords, err := vo.lookupTxt("_acme-challenge." + domain) + txtRecords, err := vo.lookupTxt(record) if err != nil { - if err = dc.storeError(db, - DNSErr(errors.Wrapf(err, "error looking up TXT "+ - "records for domain %s", domain))); err != nil { - return nil, err - } - return dc, nil + e := errors.Wrapf(err, "error looking up TXT records for domain %s", domain) + up.Error = DNSErr(e).ToACME() + return up, nil } expectedKeyAuth, err := KeyAuthorization(dc.Token, jwk) @@ -573,32 +599,39 @@ func (dc *dns01Challenge) validate(db nosql.DB, jwk *jose.JSONWebKey, vo validat } h := sha256.Sum256([]byte(expectedKeyAuth)) expected := base64.RawURLEncoding.EncodeToString(h[:]) - var found bool + + if len(txtRecords) == 0 { + e := errors.Errorf("no TXT record found at '%s'", record) + up.Error = DNSErr(e).ToACME() + return up, nil + } + for _, r := range txtRecords { if r == expected { - found = true - break + up.Validated = clock.Now() + up.Status = StatusValid + up.Error = nil + up.Retry = nil + return up, nil } } - if !found { - if err = dc.storeError(db, - RejectedIdentifierErr(errors.Errorf("keyAuthorization "+ - "does not match; expected %s, but got %s", expectedKeyAuth, txtRecords))); err != nil { - return nil, err - } - return dc, nil - } - // Update and store the challenge. - upd := &dns01Challenge{dc.baseChallenge.clone()} - upd.Status = StatusValid - upd.Error = nil - upd.Validated = time.Now().UTC() + up.Status = StatusInvalid + e := errors.Errorf("keyAuthorization does not match; expected %s, but got %s", + expectedKeyAuth, txtRecords) + up.Error = IncorrectResponseErr(e).ToACME() + return up, nil +} - if err := upd.save(db, dc); err != nil { - return nil, err +// KeyAuthorization creates the ACME key authorization value from a token +// and a jwk. +func KeyAuthorization(token string, jwk *jose.JSONWebKey) (string, error) { + thumbprint, err := jwk.Thumbprint(crypto.SHA256) + if err != nil { + return "", ServerInternalErr(errors.Wrap(err, "error generating JWK thumbprint")) } - return upd, nil + encPrint := base64.RawURLEncoding.EncodeToString(thumbprint) + return fmt.Sprintf("%s.%s", token, encPrint), nil } // getChallenge retrieves and unmarshals an ACME challenge type from the database. diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 39b33e8c6..56d577089 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -261,18 +261,25 @@ func TestNewDNS01Challenge(t *testing.T) { } } -func TestChallengeToACME(t *testing.T) { +func TestChallengeToACME_Valid(t *testing.T) { dir := newDirectory("ca.smallstep.com", "acme") - httpCh, err := newHTTPCh() - assert.FatalError(t, err) - _httpCh, ok := httpCh.(*http01Challenge) - assert.Fatal(t, ok) - _httpCh.baseChallenge.Validated = clock.Now() - dnsCh, err := newDNSCh() - assert.FatalError(t, err) - tlsALPNCh, err := newTLSALPNCh() - assert.FatalError(t, err) + n := clock.Now() + + fns := []func() (challenge, error){ + newDNSCh, + newHTTPCh, + newTLSALPNCh, + } + chs := make([]challenge, 3) + + for i, f := range fns { + ch, err := f() + assert.FatalError(t, err) + b := ch.clone() + b.Validated = n + chs[i] = b.morph() + } prov := newProv() provName := url.PathEscape(prov.GetName()) @@ -280,13 +287,78 @@ func TestChallengeToACME(t *testing.T) { ctx := context.WithValue(context.Background(), ProvisionerContextKey, prov) ctx = context.WithValue(ctx, BaseURLContextKey, baseURL) tests := map[string]challenge{ - "dns": dnsCh, - "http": httpCh, - "tls-alpn": tlsALPNCh, + "dns": chs[0], + "http": chs[1], + "tls-alpn": chs[2], + } + + for name, ch := range tests { + t.Run(name, func(t *testing.T) { + ach, err := ch.toACME(ctx, dir) + assert.FatalError(t, err) + + assert.Equals(t, ach.Type, ch.getType()) + assert.Equals(t, ach.Status, ch.getStatus()) + assert.Equals(t, ach.Token, ch.getToken()) + assert.Equals(t, ach.URL, + fmt.Sprintf("%s/acme/%s/challenge/%s", + baseURL.String(), provName, ch.getID())) + assert.Equals(t, ach.ID, ch.getID()) + assert.Equals(t, ach.AuthzID, ch.getAuthzID()) + + v, err := time.Parse(time.RFC3339, ach.Validated) + assert.FatalError(t, err) + assert.Equals(t, v, ch.getValidated()) + + assert.Equals(t, ach.RetryAfter, "") + }) + } +} + +func TestChallengeToACME_Retry(t *testing.T) { + dir := newDirectory("example.com", "acme") + + n := clock.Now() + + fns := []func() (challenge, error){ + newDNSCh, + newHTTPCh, + newTLSALPNCh, + } + states := []*Retry{ + nil, + {NextAttempt: n.Format(time.RFC3339)}, + } + chs := make([]challenge, len(fns)*len(states)) + + for i, s := range states { + for j, f := range fns { + ch, err := f() + assert.FatalError(t, err) + b := ch.clone() + b.Status = "processing" + b.Retry = s + chs[j+i*len(fns)] = b.morph() + } + } + + prov := newProv() + provName := url.PathEscape(prov.GetName()) + baseURL := &url.URL{Scheme: "https", Host: "example.com"} + ctx := context.WithValue(context.Background(), ProvisionerContextKey, prov) + ctx = context.WithValue(ctx, BaseURLContextKey, baseURL) + + tests := map[string]challenge{ + "dns_no-retry": chs[0+0*len(fns)], + "http_no-retry": chs[1+0*len(fns)], + "tls-alpn_no-retry": chs[2+0*len(fns)], + "dns_retry": chs[0+1*len(fns)], + "http_retry": chs[1+1*len(fns)], + "tls_alpn_retry": chs[2+1*len(fns)], } for name, ch := range tests { t.Run(name, func(t *testing.T) { - ach, err := ch.toACME(ctx, nil, dir) + ach, err := ch.toACME(ctx, dir) assert.FatalError(t, err) assert.Equals(t, ach.Type, ch.getType()) @@ -298,12 +370,11 @@ func TestChallengeToACME(t *testing.T) { assert.Equals(t, ach.ID, ch.getID()) assert.Equals(t, ach.AuthzID, ch.getAuthzID()) - if ach.Type == "http-01" { - v, err := time.Parse(time.RFC3339, ach.Validated) - assert.FatalError(t, err) - assert.Equals(t, v.String(), _httpCh.baseChallenge.Validated.String()) + assert.Equals(t, ach.Validated, "") + if ch.getRetry() != nil { + assert.Equals(t, ach.RetryAfter, ch.getRetry().NextAttempt) } else { - assert.Equals(t, ach.Validated, "") + assert.Equals(t, ach.RetryAfter, "") } }) } @@ -715,104 +786,119 @@ func TestHTTP01Validate(t *testing.T) { ch challenge res challenge jwk *jose.JSONWebKey - db nosql.DB err *Error } tests := map[string]func(t *testing.T) test{ - "ok/status-already-valid": func(t *testing.T) test { + + "valid/status-noop": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - _ch, ok := ch.(*http01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusValid + b := ch.clone() + b.Status = StatusValid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/status-already-invalid": func(t *testing.T) test { + + "invalid/status-noop": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - _ch, ok := ch.(*http01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusInvalid + b := ch.clone() + b.Status = StatusInvalid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/http-get-error": func(t *testing.T) test { + + "error/status-pending": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) + b := ch.clone() + b.Status = StatusPending + e := errors.New("pending challenges must first be moved to the processing state") + return test{ + ch: b.morph(), + err: ServerInternalErr(e), + } + }, + + "error/status-unknown": func(t *testing.T) test { + ch, err := newHTTPCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = "unknown" + e := errors.New("unknown challenge state: unknown") + return test{ + ch: b.morph(), + err: ServerInternalErr(e), + } + }, - expErr := ConnectionErr(errors.Errorf("error doing http GET for url "+ - "http://zap.internal/.well-known/acme-challenge/%s: force", ch.getToken())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &http01Challenge{baseClone} - newb, err := json.Marshal(newCh) + "ok/http-get-error": func(t *testing.T) test { + ch, err := newHTTPCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + + rch := ch.clone() + geterr := errors.New("force") + url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", ch.getValue(), ch.getToken()) + e := errors.Wrapf(geterr, "error doing http GET for url %s", url) + rch.Error = ConnectionErr(e).ToACME() + return test{ ch: ch, vo: validateOptions{ httpGet: func(url string) (*http.Response, error) { - return nil, errors.New("force") - }, - }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil + return nil, geterr }, }, - res: ch, + res: rch, } }, - "ok/http-get->=400": func(t *testing.T) test { + + "processing/http-get->=400": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + + rch := ch.clone() + url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", ch.getValue(), ch.getToken()) + e := errors.Errorf("error doing http GET for url %s with status code %d", url, http.StatusBadRequest) + rch.Error = ConnectionErr(e).ToACME() - expErr := ConnectionErr(errors.Errorf("error doing http GET for url "+ - "http://zap.internal/.well-known/acme-challenge/%s with status code 400", ch.getToken())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &http01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) return test{ ch: ch, vo: validateOptions{ httpGet: func(url string) (*http.Response, error) { return &http.Response{ + Body: ioutil.NopCloser(bytes.NewBufferString("")), StatusCode: http.StatusBadRequest, }, nil }, }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil - }, - }, - res: ch, + res: rch, } }, - "fail/read-body": func(t *testing.T) test { + + "processing/read-body-error": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) - assert.FatalError(t, err) - jwk.Key = "foo" + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + + rch := ch.clone() + url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", ch.getValue(), ch.getToken()) + e := errors.Wrapf(errors.New("force"), "error reading response body for url %s", url) + rch.Error = ServerInternalErr(e).ToACME() return test{ ch: ch, @@ -823,18 +909,21 @@ func TestHTTP01Validate(t *testing.T) { }, nil }, }, - jwk: jwk, - err: ServerInternalErr(errors.Errorf("error reading response "+ - "body for url http://zap.internal/.well-known/acme-challenge/%s: force", - ch.getToken())), + res: rch, } }, - "fail/key-authorization-gen-error": func(t *testing.T) test { + + "error/key-authorization-gen": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) jwk.Key = "foo" + return test{ ch: ch, vo: validateOptions{ @@ -848,25 +937,25 @@ func TestHTTP01Validate(t *testing.T) { err: ServerInternalErr(errors.New("error generating JWK thumbprint: square/go-jose: unknown key type 'string'")), } }, - "ok/key-auth-mismatch": func(t *testing.T) test { + + "invalid/key-auth-mismatch": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) - expErr := RejectedIdentifierErr(errors.Errorf("keyAuthorization does not match; "+ - "expected %s, but got foo", expKeyAuth)) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &http01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + b = ch.clone() + e := errors.Errorf("keyAuthorization does not match; expected %s, but got foo", expKeyAuth) + b.Error = IncorrectResponseErr(e).ToACME() + b.Retry = nil + b.Status = StatusInvalid + rch := b.morph() return test{ ch: ch, @@ -878,68 +967,31 @@ func TestHTTP01Validate(t *testing.T) { }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil - }, - }, - res: ch, + res: rch, } }, - "fail/save-error": func(t *testing.T) test { - ch, err := newHTTPCh() - assert.FatalError(t, err) - jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) - assert.FatalError(t, err) - - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) - assert.FatalError(t, err) - return test{ - ch: ch, - vo: validateOptions{ - httpGet: func(url string) (*http.Response, error) { - return &http.Response{ - Body: ioutil.NopCloser(bytes.NewBufferString(expKeyAuth)), - }, nil - }, - }, - jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - return nil, false, errors.New("force") - }, - }, - err: ServerInternalErr(errors.New("error saving acme challenge: force")), - } - }, - "ok": func(t *testing.T) test { + "valid/normal-http-get": func(t *testing.T) test { ch, err := newHTTPCh() assert.FatalError(t, err) - _ch, ok := ch.(*http01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Error = MalformedErr(nil).ToACME() - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) - baseClone := ch.clone() - baseClone.Status = StatusValid - baseClone.Error = nil - newCh := &http01Challenge{baseClone} + b = ch.clone() + b.Validated = clock.Now() + b.Status = StatusValid + b.Error = nil + b.Retry = nil + rch := b.morph() return test{ - ch: ch, - res: newCh, + ch: ch, vo: validateOptions{ httpGet: func(url string) (*http.Response, error) { return &http.Response{ @@ -948,30 +1000,15 @@ func TestHTTP01Validate(t *testing.T) { }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - - httpCh, err := unmarshalChallenge(newval) - assert.FatalError(t, err) - assert.Equals(t, httpCh.getStatus(), StatusValid) - assert.True(t, httpCh.getValidated().Before(time.Now().UTC().Add(time.Minute))) - assert.True(t, httpCh.getValidated().After(time.Now().UTC().Add(-1*time.Second))) - - baseClone.Validated = httpCh.getValidated() - - return nil, true, nil - }, - }, + res: rch, } }, } + for name, run := range tests { t.Run(name, func(t *testing.T) { tc := run(t) - if ch, err := tc.ch.validate(tc.db, tc.jwk, tc.vo); err != nil { + if ch, err := tc.ch.validate(tc.jwk, tc.vo); err != nil { if assert.NotNil(t, tc.err) { ae, ok := err.(*Error) assert.True(t, ok) @@ -987,8 +1024,16 @@ func TestHTTP01Validate(t *testing.T) { assert.Equals(t, tc.res.getStatus(), ch.getStatus()) assert.Equals(t, tc.res.getToken(), ch.getToken()) assert.Equals(t, tc.res.getCreated(), ch.getCreated()) - assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + if tc.res.getValidated() != ch.getValidated() { + now := clock.Now() + window := now.Sub(tc.res.getValidated()) + assert.True(t, now.Sub(ch.getValidated()) <= window, + "validated timestamp should come before now but after test case setup") + } else { + assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + } assert.Equals(t, tc.res.getError(), ch.getError()) + assert.Equals(t, tc.res.getRetry(), ch.getRetry()) } } }) @@ -1002,497 +1047,361 @@ func TestTLSALPN01Validate(t *testing.T) { ch challenge res challenge jwk *jose.JSONWebKey - db nosql.DB err *Error } tests := map[string]func(t *testing.T) test{ - "ok/status-already-valid": func(t *testing.T) test { + + "valid/status-noop": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - _ch, ok := ch.(*tlsALPN01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusValid - + b := ch.clone() + b.Status = StatusValid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/status-already-invalid": func(t *testing.T) test { + + "invalid/status-noop": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - _ch, ok := ch.(*tlsALPN01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusInvalid - + b := ch.clone() + b.Status = StatusInvalid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/tls-dial-error": func(t *testing.T) test { + + "processing/tls-dial-error": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := ConnectionErr(errors.Errorf("error doing TLS dial for %v:443: force", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := (errors.Errorf("error doing TLS dial for %v:443: force", ch.getValue())) + a.Error = ConnectionErr(e).ToACME() return test{ - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: func(network, addr string, config *tls.Config) (*tls.Conn, error) { return nil, errors.New("force") }, }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/timeout": func(t *testing.T) test { + + "processing/timeout": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := ConnectionErr(errors.Errorf("error doing TLS dial for %v:443: tls: DialWithDialer timed out", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.Errorf("error doing TLS dial for %v:443: tls: DialWithDialer timed out", ch.getValue()) + a.Error = ConnectionErr(e).ToACME() srv, tlsDial := newTestTLSALPNServer(nil) // srv.Start() - do not start server to cause timeout return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/no-certificates": func(t *testing.T) test { + + "processing/no-certificates": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := RejectedIdentifierErr(errors.Errorf("tls-alpn-01 challenge for %v resulted in no certificates", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.Errorf("tls-alpn-01 challenge for %v resulted in no certificates", ch.getValue()) + a.Error = TLSErr(e).ToACME() return test{ - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: func(network, addr string, config *tls.Config) (*tls.Conn, error) { return tls.Client(&noopConn{}, config), nil }, }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/no-names": func(t *testing.T) test { + + "processing/no-protocol": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) + b := ch.clone() + b.Status = StatusProcessing + + a := b.clone() + e := errors.New("cannot negotiate ALPN acme-tls/1 protocol for tls-alpn-01 challenge") + a.Error = TLSErr(e).ToACME() + + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expErr := RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) + srv := httptest.NewTLSServer(nil) + + return test{ + srv: srv, + ch: b.morph(), + vo: validateOptions{ + tlsDial: func(network, addr string, config *tls.Config) (*tls.Conn, error) { + return tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", srv.Listener.Addr().String(), config) + }, + }, + jwk: jwk, + res: a.morph(), + } + }, + + "processing/no-names": func(t *testing.T) test { + ch, err := newTLSALPNCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + + a := b.clone() + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue()) + a.Error = TLSErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], false, true) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/too-many-names": func(t *testing.T) test { + + "processing/too-many-names": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue()) + a.Error = TLSErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], false, true, ch.getValue(), "other.internal") assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/wrong-name": func(t *testing.T) test { + + "processing/wrong-name": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: leaf certificate must contain a single DNS name, %v", ch.getValue()) + a.Error = TLSErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], false, true, "other.internal") assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/no-extension": func(t *testing.T) test { - ch, err := newTLSALPNCh() - assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) - expErr := RejectedIdentifierErr(errors.New("incorrect certificate for tls-alpn-01 challenge: missing acmeValidationV1 extension")) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) + "processing/no-extension": func(t *testing.T) test { + ch, err := newTLSALPNCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + a := b.clone() + e := errors.New("incorrect certificate for tls-alpn-01 challenge: missing acmeValidationV1 extension") + a.Error = IncorrectResponseErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) cert, err := newTLSALPNValidationCert(nil, false, true, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/extension-not-critical": func(t *testing.T) test { + + "processing/extension-not-critical": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := RejectedIdentifierErr(errors.New("incorrect certificate for tls-alpn-01 challenge: acmeValidationV1 extension not critical")) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.New("incorrect certificate for tls-alpn-01 challenge: acmeValidationV1 extension not critical") + a.Error = IncorrectResponseErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], false, false, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/extension-malformed": func(t *testing.T) test { + + "processing/extension-malformed": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing - expErr := RejectedIdentifierErr(errors.New("incorrect certificate for tls-alpn-01 challenge: malformed acmeValidationV1 extension value")) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.New("incorrect certificate for tls-alpn-01 challenge: malformed acmeValidationV1 extension value") + a.Error = IncorrectResponseErr(e).ToACME() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) cert, err := newTLSALPNValidationCert([]byte{1, 2, 3}, false, true, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/no-protocol": func(t *testing.T) test { - ch, err := newTLSALPNCh() - assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) - - jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) - assert.FatalError(t, err) - - expErr := RejectedIdentifierErr(errors.New("cannot negotiate ALPN acme-tls/1 protocol for tls-alpn-01 challenge")) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) - srv := httptest.NewTLSServer(nil) - - return test{ - srv: srv, - ch: ch, - vo: validateOptions{ - tlsDial: func(network, addr string, config *tls.Config) (*tls.Conn, error) { - return tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", srv.Listener.Addr().String(), config) - }, - }, - jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, - } - }, - "ok/mismatched-token": func(t *testing.T) test { + "invalid/mismatched-token": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) incorrectTokenHash := sha256.Sum256([]byte("mismatched")) - expErr := RejectedIdentifierErr(errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ + a := b.clone() + e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+ "expected acmeValidationV1 extension value %s for this challenge but got %s", - hex.EncodeToString(expKeyAuthHash[:]), hex.EncodeToString(incorrectTokenHash[:]))) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + hex.EncodeToString(expKeyAuthHash[:]), hex.EncodeToString(incorrectTokenHash[:])) + a.Error = IncorrectResponseErr(e).ToACME() + a.Status = StatusInvalid cert, err := newTLSALPNValidationCert(incorrectTokenHash[:], false, true, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok/obsolete-oid": func(t *testing.T) test { + + "processing/obsolete-oid": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expErr := RejectedIdentifierErr(errors.New("incorrect certificate for tls-alpn-01 challenge: " + - "obsolete id-pe-acmeIdentifier in acmeValidationV1 extension")) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &tlsALPN01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + a := b.clone() + e := errors.New("incorrect certificate for tls-alpn-01 challenge: " + + "obsolete id-pe-acmeIdentifier in acmeValidationV1 extension") + a.Error = IncorrectResponseErr(e).ToACME() expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) @@ -1500,92 +1409,63 @@ func TestTLSALPN01Validate(t *testing.T) { cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], true, true, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: tlsDial, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, string(newval), string(newb)) - return nil, true, nil - }, - }, - res: ch, + res: a.morph(), } }, - "ok": func(t *testing.T) test { + + "valid/expected-identifier": func(t *testing.T) test { ch, err := newTLSALPNCh() assert.FatalError(t, err) - _ch, ok := ch.(*tlsALPN01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Error = MalformedErr(nil).ToACME() - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) - - baseClone := ch.clone() - baseClone.Status = StatusValid - baseClone.Error = nil - newCh := &tlsALPN01Challenge{baseClone} + b := ch.clone() + b.Status = StatusProcessing jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) expKeyAuthHash := sha256.Sum256([]byte(expKeyAuth)) + a := b.clone() + a.Validated = clock.Now() + a.Status = StatusValid + a.Error = nil + a.Retry = nil + cert, err := newTLSALPNValidationCert(expKeyAuthHash[:], false, true, ch.getValue()) assert.FatalError(t, err) - srv, tlsDial := newTestTLSALPNServer(cert) srv.Start() return test{ srv: srv, - ch: ch, + ch: b.morph(), vo: validateOptions{ tlsDial: func(network, addr string, config *tls.Config) (conn *tls.Conn, err error) { assert.Equals(t, network, "tcp") - assert.Equals(t, addr, net.JoinHostPort(newCh.getValue(), "443")) + assert.Equals(t, addr, net.JoinHostPort(ch.getValue(), "443")) assert.Equals(t, config.NextProtos, []string{"acme-tls/1"}) - assert.Equals(t, config.ServerName, newCh.getValue()) + assert.Equals(t, config.ServerName, ch.getValue()) assert.True(t, config.InsecureSkipVerify) return tlsDial(network, addr, config) }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - - alpnCh, err := unmarshalChallenge(newval) - assert.FatalError(t, err) - assert.Equals(t, alpnCh.getStatus(), StatusValid) - assert.True(t, alpnCh.getValidated().Before(time.Now().UTC().Add(time.Minute))) - assert.True(t, alpnCh.getValidated().After(time.Now().UTC().Add(-1*time.Second))) - - baseClone.Validated = alpnCh.getValidated() - - return nil, true, nil - }, - }, - res: newCh, + res: a.morph(), } }, } + for name, run := range tests { t.Run(name, func(t *testing.T) { tc := run(t) @@ -1594,7 +1474,7 @@ func TestTLSALPN01Validate(t *testing.T) { defer tc.srv.Close() } - if ch, err := tc.ch.validate(tc.db, tc.jwk, tc.vo); err != nil { + if ch, err := tc.ch.validate(tc.jwk, tc.vo); err != nil { if assert.NotNil(t, tc.err) { ae, ok := err.(*Error) assert.True(t, ok) @@ -1611,7 +1491,16 @@ func TestTLSALPN01Validate(t *testing.T) { assert.Equals(t, tc.res.getToken(), ch.getToken()) assert.Equals(t, tc.res.getCreated(), ch.getCreated()) assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + if tc.res.getValidated() != ch.getValidated() { + now := clock.Now() + window := now.Sub(tc.res.getValidated()) + assert.True(t, now.Sub(ch.getValidated()) <= window, + "validated timestamp should come before now but after test case setup") + } else { + assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + } assert.Equals(t, tc.res.getError(), ch.getError()) + assert.Equals(t, tc.res.getRetry(), ch.getRetry()) } } }) @@ -1716,45 +1605,70 @@ func TestDNS01Validate(t *testing.T) { ch challenge res challenge jwk *jose.JSONWebKey - db nosql.DB err *Error } tests := map[string]func(t *testing.T) test{ - "ok/status-already-valid": func(t *testing.T) test { + + "valid/status-noop": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - _ch, ok := ch.(*dns01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusValid + b := ch.clone() + b.Status = StatusValid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/status-already-invalid": func(t *testing.T) test { + + "invalid/status-noop": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - _ch, ok := ch.(*dns01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Status = StatusInvalid + b := ch.clone() + b.Status = StatusInvalid + ch = b.morph() return test{ ch: ch, res: ch, } }, - "ok/lookup-txt-error": func(t *testing.T) test { + + "error/status-pending": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) + b := ch.clone() + b.Status = StatusPending + e := errors.New("pending challenges must first be moved to the processing state") + return test{ + ch: b.morph(), + err: ServerInternalErr(e), + } + }, + + "error/status-unknown": func(t *testing.T) test { + ch, err := newDNSCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = "unknown" + e := errors.New("unknown challenge state: unknown") + return test{ + ch: b.morph(), + err: ServerInternalErr(e), + } + }, - expErr := DNSErr(errors.Errorf("error looking up TXT records for "+ - "domain %s: force", ch.getValue())) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &dns01Challenge{baseClone} - newb, err := json.Marshal(newCh) + "processing/lookup-txt-error": func(t *testing.T) test { + ch, err := newDNSCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + + b = ch.clone() + e := errors.Errorf("error looking up TXT records for domain %s: force", ch.getValue()) + b.Error = DNSErr(e).ToACME() + rch := b.morph() + return test{ ch: ch, vo: validateOptions{ @@ -1762,65 +1676,52 @@ func TestDNS01Validate(t *testing.T) { return nil, errors.New("force") }, }, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil - }, - }, - res: ch, + res: rch, } }, - "ok/lookup-txt-wildcard": func(t *testing.T) test { + + "fail/key-authorization-gen-error": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - _ch, ok := ch.(*dns01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Value = "*.zap.internal" + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) - assert.FatalError(t, err) - h := sha256.Sum256([]byte(expKeyAuth)) - expected := base64.RawURLEncoding.EncodeToString(h[:]) - - baseClone := ch.clone() - baseClone.Status = StatusValid - baseClone.Error = nil - newCh := &dns01Challenge{baseClone} + jwk.Key = "foo" return test{ - ch: ch, - res: newCh, + ch: ch, vo: validateOptions{ lookupTxt: func(url string) ([]string, error) { - assert.Equals(t, url, "_acme-challenge.zap.internal") - return []string{"foo", expected}, nil + return []string{"foo", "bar"}, nil }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - dnsCh, err := unmarshalChallenge(newval) - assert.FatalError(t, err) - assert.Equals(t, dnsCh.getStatus(), StatusValid) - baseClone.Validated = dnsCh.getValidated() - return nil, true, nil - }, - }, + err: ServerInternalErr(errors.New("error generating JWK thumbprint: square/go-jose: unknown key type 'string'")), } }, - "fail/key-authorization-gen-error": func(t *testing.T) test { + + "invalid/key-auth-mismatch": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - jwk.Key = "foo" + expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) + assert.FatalError(t, err) + + e := errors.Errorf("keyAuthorization does not match; "+ + "expected %s, but got %s", expKeyAuth, []string{"foo", "bar"}) + b = ch.clone() + b.Status = StatusInvalid + b.Error = IncorrectResponseErr(e).ToACME() + rch := b.morph() + return test{ ch: ch, vo: validateOptions{ @@ -1829,60 +1730,58 @@ func TestDNS01Validate(t *testing.T) { }, }, jwk: jwk, - err: ServerInternalErr(errors.New("error generating JWK thumbprint: square/go-jose: unknown key type 'string'")), + res: rch, } }, - "ok/key-auth-mismatch": func(t *testing.T) test { + + "processing/empty-list": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) - assert.FatalError(t, err) - - expErr := RejectedIdentifierErr(errors.Errorf("keyAuthorization does not match; "+ - "expected %s, but got %s", expKeyAuth, []string{"foo", "bar"})) - baseClone := ch.clone() - baseClone.Error = expErr.ToACME() - newCh := &http01Challenge{baseClone} - newb, err := json.Marshal(newCh) - assert.FatalError(t, err) + e := errors.New("no TXT record found at '_acme-challenge.zap.internal'") + b = ch.clone() + b.Error = DNSErr(e).ToACME() + rch := b.morph() return test{ ch: ch, vo: validateOptions{ lookupTxt: func(url string) ([]string, error) { - return []string{"foo", "bar"}, nil + return []string{}, nil }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - assert.Equals(t, newval, newb) - return nil, true, nil - }, - }, - res: ch, + res: rch, } }, - "fail/save-error": func(t *testing.T) test { + + "valid/lookup-txt-normal": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) - expKeyAuth, err := KeyAuthorization(ch.getToken(), jwk) assert.FatalError(t, err) h := sha256.Sum256([]byte(expKeyAuth)) expected := base64.RawURLEncoding.EncodeToString(h[:]) + + b = ch.clone() + b.Validated = clock.Now() + b.Status = StatusValid + b.Error = nil + b.Retry = nil + rch := b.morph() + return test{ ch: ch, vo: validateOptions{ @@ -1891,22 +1790,17 @@ func TestDNS01Validate(t *testing.T) { }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - return nil, false, errors.New("force") - }, - }, - err: ServerInternalErr(errors.New("error saving acme challenge: force")), + res: rch, } }, - "ok": func(t *testing.T) test { + + "valid/lookup-txt-wildcard": func(t *testing.T) test { ch, err := newDNSCh() assert.FatalError(t, err) - _ch, ok := ch.(*dns01Challenge) - assert.Fatal(t, ok) - _ch.baseChallenge.Error = MalformedErr(nil).ToACME() - oldb, err := json.Marshal(ch) - assert.FatalError(t, err) + b := ch.clone() + b.Status = StatusProcessing + b.Value = "*.zap.internal" + ch = b.morph() jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) assert.FatalError(t, err) @@ -1916,44 +1810,31 @@ func TestDNS01Validate(t *testing.T) { h := sha256.Sum256([]byte(expKeyAuth)) expected := base64.RawURLEncoding.EncodeToString(h[:]) - baseClone := ch.clone() - baseClone.Status = StatusValid - baseClone.Error = nil - newCh := &dns01Challenge{baseClone} + b = ch.clone() + b.Status = StatusValid + b.Validated = clock.Now() + b.Error = nil + b.Retry = nil + rch := b.morph() return test{ - ch: ch, - res: newCh, + ch: ch, vo: validateOptions{ lookupTxt: func(url string) ([]string, error) { + assert.Equals(t, url, "_acme-challenge.zap.internal") return []string{"foo", expected}, nil }, }, jwk: jwk, - db: &db.MockNoSQLDB{ - MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { - assert.Equals(t, bucket, challengeTable) - assert.Equals(t, key, []byte(ch.getID())) - assert.Equals(t, old, oldb) - - dnsCh, err := unmarshalChallenge(newval) - assert.FatalError(t, err) - assert.Equals(t, dnsCh.getStatus(), StatusValid) - assert.True(t, dnsCh.getValidated().Before(time.Now().UTC())) - assert.True(t, dnsCh.getValidated().After(time.Now().UTC().Add(-1*time.Second))) - - baseClone.Validated = dnsCh.getValidated() - - return nil, true, nil - }, - }, + res: rch, } }, } + for name, run := range tests { t.Run(name, func(t *testing.T) { tc := run(t) - if ch, err := tc.ch.validate(tc.db, tc.jwk, tc.vo); err != nil { + if ch, err := tc.ch.validate(tc.jwk, tc.vo); err != nil { if assert.NotNil(t, tc.err) { ae, ok := err.(*Error) assert.True(t, ok) @@ -1969,8 +1850,16 @@ func TestDNS01Validate(t *testing.T) { assert.Equals(t, tc.res.getStatus(), ch.getStatus()) assert.Equals(t, tc.res.getToken(), ch.getToken()) assert.Equals(t, tc.res.getCreated(), ch.getCreated()) - assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + if tc.res.getValidated() != ch.getValidated() { + now := clock.Now() + window := now.Sub(tc.res.getValidated()) + assert.True(t, now.Sub(ch.getValidated()) <= window, + "validated timestamp should come before now but after test case setup") + } else { + assert.Equals(t, tc.res.getValidated(), ch.getValidated()) + } assert.Equals(t, tc.res.getError(), ch.getError()) + assert.Equals(t, tc.res.getRetry(), ch.getRetry()) } } }) diff --git a/acme/common.go b/acme/common.go index 8b878016c..08c609d11 100644 --- a/acme/common.go +++ b/acme/common.go @@ -97,6 +97,8 @@ var ( StatusInvalid = "invalid" // StatusPending -- pending; e.g. an Order that is not ready to be finalized. StatusPending = "pending" + // StatusProcessing -- processing e.g. a Challenge that is in the process of being validated. + StatusProcessing = "processing" // StatusDeactivated -- deactivated; e.g. for an Account that is not longer valid. StatusDeactivated = "deactivated" // StatusReady -- ready; e.g. for an Order that is ready to be finalized. diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 95115e6dc..eabba9513 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -58,6 +58,8 @@ func (p *ACME) Init(config Config) (err error) { return err } + // TODO: https://github.com/smallstep/certificates/issues/250 + return err } diff --git a/ca/ca.go b/ca/ca.go index 96bebba47..469c2a255 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -26,6 +26,7 @@ type options struct { configFile string password []byte database db.AuthDB + ordinal int } func (o *options) apply(opts []Option) { @@ -60,6 +61,13 @@ func WithDatabase(db db.AuthDB) Option { } } +// WithOrdinal sets the server's ordinal identifier (an int). +func WithOrdinal(ordinal int) Option { + return func(o *options) { + o.ordinal = ordinal + } +} + // CA is the type used to build the complete certificate authority. It builds // the HTTP server, set ups the middlewares and the HTTP handlers. type CA struct { @@ -124,7 +132,7 @@ func (ca *CA) Init(config *authority.Config) (*CA, error) { } prefix := "acme" - acmeAuth, err := acme.NewAuthority(auth.GetDatabase().(nosql.DB), dns, prefix, auth) + acmeAuth, err := acme.NewAuthority(auth.GetDatabase().(nosql.DB), dns, prefix, auth, ca.opts.ordinal) if err != nil { return nil, errors.Wrap(err, "error creating ACME authority") } diff --git a/commands/app.go b/commands/app.go index 9b22ac2d3..9ab367bf3 100644 --- a/commands/app.go +++ b/commands/app.go @@ -34,6 +34,11 @@ intermediate private key.`, Name: "resolver", Usage: "address of a DNS resolver to be used instead of the default.", }, + cli.IntFlag{ + Name: "ordinal", + Usage: `Unique identifying this instance of step-ca in a highly- +available (replicated) deployment.`, + }, }, } @@ -42,6 +47,9 @@ func appAction(ctx *cli.Context) error { passFile := ctx.String("password-file") resolver := ctx.String("resolver") + // grab the ordinal or default to 0 + ordinal := ctx.Int("ordinal") + // If zero cmd line args show help, if >1 cmd line args show error. if ctx.NArg() == 0 { return cli.ShowAppHelp(ctx) @@ -72,7 +80,7 @@ func appAction(ctx *cli.Context) error { } } - srv, err := ca.New(config, ca.WithConfigFile(configFile), ca.WithPassword(password)) + srv, err := ca.New(config, ca.WithConfigFile(configFile), ca.WithPassword(password), ca.WithOrdinal(ordinal)) if err != nil { fatal(err) } diff --git a/go.mod b/go.mod index 9cbf14189..5dc25df0b 100644 --- a/go.mod +++ b/go.mod @@ -8,21 +8,26 @@ require ( github.com/go-chi/chi v4.0.2+incompatible github.com/googleapis/gax-go/v2 v2.0.5 github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect + github.com/kr/text v0.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/newrelic/go-agent v2.15.0+incompatible + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pkg/errors v0.8.1 github.com/rs/xid v1.2.1 github.com/sirupsen/logrus v1.4.2 github.com/smallstep/assert v0.0.0-20200103212524-b99dc1097b15 github.com/smallstep/cli v0.14.3 github.com/smallstep/nosql v0.3.0 + github.com/stretchr/testify v1.5.1 // indirect github.com/urfave/cli v1.22.2 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 - golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b google.golang.org/api v0.15.0 google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb google.golang.org/grpc v1.26.0 + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/square/go-jose.v2 v2.4.0 + gopkg.in/yaml.v2 v2.2.8 // indirect ) //replace github.com/smallstep/cli => ../cli diff --git a/go.sum b/go.sum index 8bb4b7716..0d6f07a22 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -300,6 +301,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/pkcs11key v2.0.1-0.20170608213348-396559074696+incompatible h1:GfzE+uq7odDW7nOmp1QWuilLEK7kJf8i84XcIfk3mKA= github.com/letsencrypt/pkcs11key v2.0.1-0.20170608213348-396559074696+incompatible/go.mod h1:iGYXKqDXt0cpBthCHdr9ZdsQwyGlYFh/+8xa4WzIQ34= @@ -362,6 +365,8 @@ github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1 github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= github.com/newrelic/go-agent v2.15.0+incompatible h1:IB0Fy+dClpBq9aEoIrLyQXzU34JyI1xVTanPLB/+jvU= github.com/newrelic/go-agent v2.15.0+incompatible/go.mod h1:a8Fv1b/fYhFSReoTU6HDkTYIMZeSVNffmoS726Y0LzQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= @@ -498,6 +503,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/timakin/bodyclose v0.0.0-20190721030226-87058b9bfcec/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= @@ -616,6 +623,8 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -749,6 +758,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.28 h1:n1tBJnnK2r7g9OW2btFH91V92STTUevLXYFb8gy9EMk= gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= @@ -770,6 +781,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=