Compare commits

...

37 commits

Author SHA1 Message Date
David Cowden
112fc59f46 make: Fix lint errors
Add `golanglint-ci` to the modules so it's available when running `make
lint`.
2020-05-20 21:10:27 -07:00
David Cowden
6c39439008 acme: make fmt 2020-05-20 20:41:07 -07:00
David Cowden
05780554d2 acme/retry: Cleanup tls-alpn-01 tests
This logic was already in the correct form so it was much easier to
update.
2020-05-20 20:32:02 -07:00
David Cowden
d54f963b81 acme/retry: Update DNS challenge tests 2020-05-20 18:02:58 -07:00
David Cowden
0f63e43b10 acme: Update http-01 challenge tests
Add tests for the starting challenge statuses.
Removed unneeded db write test.
2020-05-19 03:38:04 -07:00
David Cowden
9103880f88 Merge branch 'master' into dcow/challenge-retry 2020-05-18 23:00:25 -07:00
David Cowden
d5f95dee57 Merge branch 'master' into dcow/challenge-retry 2020-05-18 04:06:30 -07:00
David Cowden
deacbdc358 acme: Don't panic on logic errors
Since it will ultimately 500 anyway, just return an error.
2020-05-13 20:06:50 -07:00
David Cowden
f0228183f5 project: go mod tidy 2020-05-13 19:41:35 -07:00
David Cowden
c378e0043a acme: Move ordinal to application
The authority now receives the ordinal in its constructor rather than a
global variable set at package initialization time. The ordinal is
passed via the command line option `--ordinal`.
2020-05-13 19:22:07 -07:00
David Cowden
b8b3ca2ac1 acme/authority: Add descriptive intro to ValidateChallenge 2020-05-13 11:38:40 -07:00
David Cowden
5e5a76c3b5 acme/api: Set Link and Location headers for all 200
On the challenge resource, set "Link" and "Location" headers for all
successful requests to the challenge resource.
2020-05-13 11:10:14 -07:00
David Cowden
5354906b9c acme/api: Add func name to beginning of comment 2020-05-13 10:56:19 -07:00
David Cowden
976c8f82c6 acme/authority: Fix tests
Also, return early from ValidateChallenge if the challenge is already
valid. Interestingly, we aren't actually testing most of the
ValidateChallenge func, just the early error and return conditions. We
should add some more coverage here.
2020-05-13 07:55:38 -07:00
David Cowden
b061d0af34 acme/authority: Fix error message in test
The error message was updated. Make the test should reflect the new
changes.
2020-05-13 07:31:21 -07:00
David Cowden
609e1312da acme/api: Write headers for invalid challenges
Include the "Link" and "Location" headers on invalid challenge
resources. An invalid challenge is still a perfectly acceptable
response.
2020-05-13 07:29:12 -07:00
David Cowden
8ae32f50f2 acme: Fix comment style to appease linter
The linter likes comments on public functions to start with their name,
for some reason...
2020-05-13 05:04:49 -07:00
David Cowden
794725bcc3 acme/api: Remove unused BackoffChallenge func
The mock has an old func that is no longer used. Remove it.
2020-05-13 04:03:47 -07:00
David Cowden
8556d45c3f acme/authority: Move comment onto correct block
The comment appeared too early.
2020-05-13 04:03:01 -07:00
David Cowden
84af2ad562 acme: Fix test compile
* Add toACME test for the "processing" state.
2020-05-12 08:33:32 -07:00
David Cowden
2514b58f58 acme/api: Fixup handler_test
Remove superfluous test. Add test checking for the Retry-After header if
the challenge's RetryAfter field is set.
2020-05-12 04:52:44 -07:00
David Cowden
089e3aea4f acme/challenge: Fix error return type on KeyAuthorization
In golang, one should always return error types rather than interfaces
that conform to an error protocol. Why? Because of this:

    https://play.golang.org/p/MVa5vowuNRo

Feels ~~like JavaScript~~ bad, man.
2020-05-11 21:30:50 -07:00
David Cowden
9f18882973 acme/challenge: Copy retry information on clone
When cloning a challenge, deeply clone the retry field if it is not nil.
2020-05-11 21:25:31 -07:00
David Cowden
a857c45847 acme/authority: Polymorph the challenge type
Prior to validation, we must wrap the base challenge in the correct
concrete challenge type so that we dispatch the correct validation
method.
2020-05-11 21:23:55 -07:00
David Cowden
2d0a00c4e1 acme/api: Add missing return
Stop execution when the error happens. This was previously a typo.
2020-05-11 21:22:40 -07:00
David Cowden
8326632f5b vscode: Ignore vscode binaries
It might make sense to check in the vscode workspace file if we can make
everything relative to the project directory.
2020-05-11 18:47:07 -07:00
David Cowden
9518ba44b1 provisioner/acme: Add TODO for retry restarts
The comment in acme/authority directs users to this file so put a TODO
in for posterity.
2020-05-11 18:46:15 -07:00
David Cowden
bdadea8a37 acme: go fmt 2020-05-07 09:27:16 -07:00
David Cowden
9af4dd3692 acme: Retry challenge validation attempts
Section 8.2 of RFC 8555 explains how retries apply to the validation
process. However, much is left up to the implementer.

Add retries every 12 seconds for 2 minutes after a client requests a
validation. The challenge status remains "processing" indefinitely until
a distinct conclusion is reached. This allows a client to continually
re-request a validation by sending a post-get to the challenge resource
until the process fails or succeeds.

Challenges in the processing state include information about why a
validation did not complete in the error field. The server also includes
a Retry-After header to help clients and servers coordinate.

Retries are inherently stateful because they're part of the public API.
When running step-ca in a highly available setup with replicas, care
must be taken to maintain a persistent identifier for each instance
"slot". In kubernetes, this implies a *stateful set*.
2020-05-06 07:39:13 -07:00
David Cowden
5e6a020da5 acme/authority: Add space around *
Makes the line more readable.
2020-04-30 04:44:36 -07:00
David Cowden
f56c449ea4 handler_test: Add BackoffChallenge
The mock acme authority needs to in order to conform to the updated acme
authority interface.
2020-04-30 04:44:08 -07:00
David Cowden
8fb558da10 handler_test: Remove unused field "Backoffs" 2020-04-30 04:44:08 -07:00
Wesley Graham
8d4356733e Implement standard backoff strategy 2020-04-30 04:44:08 -07:00
Wesley Graham
f9779d0bed Polish retry conditions 2020-04-30 04:44:08 -07:00
Wesley Graham
66b2c4b1a4 Add automated challenge retries, RFC 8555 2020-04-30 04:44:08 -07:00
Wesley Graham
40d7c42e33 Implement acme RFC 8555, challenge retries 2020-04-30 04:44:08 -07:00
David Cowden
6fdbd856f7 git: Ignore *.code-workspace
These are visual studio code's workspace configuration files.
2020-04-30 04:44:08 -07:00
15 changed files with 1273 additions and 1004 deletions

4
.gitignore vendored
View file

@ -19,3 +19,7 @@ coverage.txt
vendor
output
.idea
# vscode
*.code-workspace
*_bin

View file

@ -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)
}

View file

@ -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")
}
})
}

View file

@ -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.

View file

@ -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,

View file

@ -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
}

View file

@ -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)

View file

@ -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
}
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.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 {
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
}
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
// success
if keyAuth == expected {
up.Validated = clock.Now()
up.Status = StatusValid
up.Error = nil
up.Retry = nil
return up, 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
}
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,6 +559,70 @@ func newDNS01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) {
return dc, 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(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) {
// If already valid or invalid then return without performing validation.
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(record)
if err != 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)
if err != nil {
return nil, err
}
h := sha256.Sum256([]byte(expectedKeyAuth))
expected := base64.RawURLEncoding.EncodeToString(h[:])
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 {
up.Validated = clock.Now()
up.Status = StatusValid
up.Error = nil
up.Retry = nil
return up, nil
}
}
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
}
// KeyAuthorization creates the ACME key authorization value from a token
// and a jwk.
func KeyAuthorization(token string, jwk *jose.JSONWebKey) (string, error) {
@ -542,65 +634,6 @@ func KeyAuthorization(token string, jwk *jose.JSONWebKey) (string, error) {
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) {
// If already valid or invalid then return without performing validation.
if dc.getStatus() == StatusValid || dc.getStatus() == StatusInvalid {
return dc, nil
}
// 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, "*.")
txtRecords, err := vo.lookupTxt("_acme-challenge." + domain)
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
}
expectedKeyAuth, err := KeyAuthorization(dc.Token, jwk)
if err != nil {
return nil, err
}
h := sha256.Sum256([]byte(expectedKeyAuth))
expected := base64.RawURLEncoding.EncodeToString(h[:])
var found bool
for _, r := range txtRecords {
if r == expected {
found = true
break
}
}
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()
if err := upd.save(db, dc); err != nil {
return nil, err
}
return upd, nil
}
// getChallenge retrieves and unmarshals an ACME challenge type from the database.
func getChallenge(db nosql.DB, id string) (challenge, error) {
b, err := db.Get(challengeTable, []byte(id))

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -58,6 +58,8 @@ func (p *ACME) Init(config Config) (err error) {
return err
}
// TODO: https://github.com/smallstep/certificates/issues/250
return err
}

View file

@ -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")
}

View file

@ -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 <index> 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)
}

7
go.mod
View file

@ -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

13
go.sum
View file

@ -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=