Skip to content

Commit c57dfee

Browse files
authored
Merge pull request smallstep#650 from hslatman/hs/acme-eab
ACME External Account Binding
2 parents 09a9b3e + bf21319 commit c57dfee

31 files changed

+8061
-299
lines changed

acme/account.go

+36-5
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@ import (
44
"crypto"
55
"encoding/base64"
66
"encoding/json"
7+
"time"
78

89
"go.step.sm/crypto/jose"
910
)
1011

1112
// Account is a subset of the internal account type containing only those
1213
// attributes required for responses in the ACME protocol.
1314
type Account struct {
14-
ID string `json:"-"`
15-
Key *jose.JSONWebKey `json:"-"`
16-
Contact []string `json:"contact,omitempty"`
17-
Status Status `json:"status"`
18-
OrdersURL string `json:"orders"`
15+
ID string `json:"-"`
16+
Key *jose.JSONWebKey `json:"-"`
17+
Contact []string `json:"contact,omitempty"`
18+
Status Status `json:"status"`
19+
OrdersURL string `json:"orders"`
20+
ExternalAccountBinding interface{} `json:"externalAccountBinding,omitempty"`
1921
}
2022

2123
// ToLog enables response logging.
@@ -40,3 +42,32 @@ func KeyToID(jwk *jose.JSONWebKey) (string, error) {
4042
}
4143
return base64.RawURLEncoding.EncodeToString(kid), nil
4244
}
45+
46+
// ExternalAccountKey is an ACME External Account Binding key.
47+
type ExternalAccountKey struct {
48+
ID string `json:"id"`
49+
ProvisionerID string `json:"provisionerID"`
50+
Reference string `json:"reference"`
51+
AccountID string `json:"-"`
52+
KeyBytes []byte `json:"-"`
53+
CreatedAt time.Time `json:"createdAt"`
54+
BoundAt time.Time `json:"boundAt,omitempty"`
55+
}
56+
57+
// AlreadyBound returns whether this EAK is already bound to
58+
// an ACME Account or not.
59+
func (eak *ExternalAccountKey) AlreadyBound() bool {
60+
return !eak.BoundAt.IsZero()
61+
}
62+
63+
// BindTo binds the EAK to an Account.
64+
// It returns an error if it's already bound.
65+
func (eak *ExternalAccountKey) BindTo(account *Account) error {
66+
if eak.AlreadyBound() {
67+
return NewError(ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", eak.ID, eak.AccountID, eak.BoundAt)
68+
}
69+
eak.AccountID = account.ID
70+
eak.BoundAt = time.Now()
71+
eak.KeyBytes = []byte{} // clearing the key bytes; can only be used once
72+
return nil
73+
}

acme/account_test.go

+65
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto"
55
"encoding/base64"
66
"testing"
7+
"time"
78

89
"github.com/pkg/errors"
910
"github.com/smallstep/assert"
@@ -79,3 +80,67 @@ func TestAccount_IsValid(t *testing.T) {
7980
})
8081
}
8182
}
83+
84+
func TestExternalAccountKey_BindTo(t *testing.T) {
85+
boundAt := time.Now()
86+
tests := []struct {
87+
name string
88+
eak *ExternalAccountKey
89+
acct *Account
90+
err *Error
91+
}{
92+
{
93+
name: "ok",
94+
eak: &ExternalAccountKey{
95+
ID: "eakID",
96+
ProvisionerID: "provID",
97+
Reference: "ref",
98+
KeyBytes: []byte{1, 3, 3, 7},
99+
},
100+
acct: &Account{
101+
ID: "accountID",
102+
},
103+
err: nil,
104+
},
105+
{
106+
name: "fail/already-bound",
107+
eak: &ExternalAccountKey{
108+
ID: "eakID",
109+
ProvisionerID: "provID",
110+
Reference: "ref",
111+
KeyBytes: []byte{1, 3, 3, 7},
112+
AccountID: "someAccountID",
113+
BoundAt: boundAt,
114+
},
115+
acct: &Account{
116+
ID: "accountID",
117+
},
118+
err: NewError(ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", "eakID", "someAccountID", boundAt),
119+
},
120+
}
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
eak := tt.eak
124+
acct := tt.acct
125+
err := eak.BindTo(acct)
126+
wantErr := tt.err != nil
127+
gotErr := err != nil
128+
if wantErr != gotErr {
129+
t.Errorf("ExternalAccountKey.BindTo() error = %v, wantErr %v", err, tt.err)
130+
}
131+
if wantErr {
132+
assert.NotNil(t, err)
133+
assert.Type(t, &Error{}, err)
134+
ae, _ := err.(*Error)
135+
assert.Equals(t, ae.Type, tt.err.Type)
136+
assert.Equals(t, ae.Detail, tt.err.Detail)
137+
assert.Equals(t, ae.Identifier, tt.err.Identifier)
138+
assert.Equals(t, ae.Subproblems, tt.err.Subproblems)
139+
} else {
140+
assert.Equals(t, eak.AccountID, acct.ID)
141+
assert.Equals(t, eak.KeyBytes, []byte{})
142+
assert.NotNil(t, eak.BoundAt)
143+
}
144+
})
145+
}
146+
}

acme/api/account.go

+32-5
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import (
1212

1313
// NewAccountRequest represents the payload for a new account request.
1414
type NewAccountRequest struct {
15-
Contact []string `json:"contact"`
16-
OnlyReturnExisting bool `json:"onlyReturnExisting"`
17-
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
15+
Contact []string `json:"contact"`
16+
OnlyReturnExisting bool `json:"onlyReturnExisting"`
17+
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
18+
ExternalAccountBinding *ExternalAccountBinding `json:"externalAccountBinding,omitempty"`
1819
}
1920

2021
func validateContacts(cs []string) error {
@@ -83,8 +84,14 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
8384
return
8485
}
8586

87+
prov, err := acmeProvisionerFromContext(ctx)
88+
if err != nil {
89+
api.WriteError(w, err)
90+
return
91+
}
92+
8693
httpStatus := http.StatusCreated
87-
acc, err := accountFromContext(r.Context())
94+
acc, err := accountFromContext(ctx)
8895
if err != nil {
8996
acmeErr, ok := err.(*acme.Error)
9097
if !ok || acmeErr.Status != http.StatusBadRequest {
@@ -99,12 +106,19 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
99106
"account does not exist"))
100107
return
101108
}
109+
102110
jwk, err := jwkFromContext(ctx)
103111
if err != nil {
104112
api.WriteError(w, err)
105113
return
106114
}
107115

116+
eak, err := h.validateExternalAccountBinding(ctx, &nar)
117+
if err != nil {
118+
api.WriteError(w, err)
119+
return
120+
}
121+
108122
acc = &acme.Account{
109123
Key: jwk,
110124
Contact: nar.Contact,
@@ -114,8 +128,21 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
114128
api.WriteError(w, acme.WrapErrorISE(err, "error creating account"))
115129
return
116130
}
131+
132+
if eak != nil { // means that we have a (valid) External Account Binding key that should be bound, updated and sent in the response
133+
err := eak.BindTo(acc)
134+
if err != nil {
135+
api.WriteError(w, err)
136+
return
137+
}
138+
if err := h.db.UpdateExternalAccountKey(ctx, prov.ID, eak); err != nil {
139+
api.WriteError(w, acme.WrapErrorISE(err, "error updating external account binding key"))
140+
return
141+
}
142+
acc.ExternalAccountBinding = nar.ExternalAccountBinding
143+
}
117144
} else {
118-
// Account exists //
145+
// Account exists
119146
httpStatus = http.StatusOK
120147
}
121148

0 commit comments

Comments
 (0)