Skip to content

Commit 403b017

Browse files
jason-bakerrolandshoemaker
authored andcommitted
acme: add AccountKeyRollover
Add support for AccountKeyRollover. API only returns an error since acme.Error will contain appropriate KID lookup information. Due to the requirements of double JWS encoding jwsEncodeJSON is also modified to support a missing Nonce header and raw string embedding in the payload. Fixes golang/go#42516 Change-Id: I959660a1a39b2c469b959accd48fda519daf4eb3 GitHub-Last-Rev: 8e8cc5b GitHub-Pull-Request: #215 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/400274 TryBot-Result: Gopher Robot <gobot@golang.org> Reviewed-by: Heschi Kreinick <heschi@google.com> Reviewed-by: Roland Shoemaker <roland@golang.org> Run-TryBot: Roland Shoemaker <roland@golang.org>
1 parent 4661260 commit 403b017

File tree

5 files changed

+142
-6
lines changed

5 files changed

+142
-6
lines changed

acme/acme.go

+14
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,20 @@ func (c *Client) UpdateReg(ctx context.Context, acct *Account) (*Account, error)
306306
return c.updateRegRFC(ctx, acct)
307307
}
308308

309+
// AccountKeyRollover attempts to transition a client's account key to a new key.
310+
// On success client's Key is updated which is not concurrency safe.
311+
// On failure an error will be returned.
312+
// The new key is already registered with the ACME provider if the following is true:
313+
// - error is of type acme.Error
314+
// - StatusCode should be 409 (Conflict)
315+
// - Location header will have the KID of the associated account
316+
//
317+
// More about account key rollover can be found at
318+
// https://tools.ietf.org/html/rfc8555#section-7.3.5.
319+
func (c *Client) AccountKeyRollover(ctx context.Context, newKey crypto.Signer) error {
320+
return c.accountKeyRollover(ctx, newKey)
321+
}
322+
309323
// Authorize performs the initial step in the pre-authorization flow,
310324
// as opposed to order-based flow.
311325
// The caller will then need to choose from and perform a set of returned

acme/jws.go

+31-6
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const noKeyID = KeyID("")
3333
// See https://tools.ietf.org/html/rfc8555#section-6.3 for more details.
3434
const noPayload = ""
3535

36+
// noNonce indicates that the nonce should be omitted from the protected header.
37+
// See jwsEncodeJSON for details.
38+
const noNonce = ""
39+
3640
// jsonWebSignature can be easily serialized into a JWS following
3741
// https://tools.ietf.org/html/rfc7515#section-3.2.
3842
type jsonWebSignature struct {
@@ -45,10 +49,15 @@ type jsonWebSignature struct {
4549
// The result is serialized in JSON format containing either kid or jwk
4650
// fields based on the provided KeyID value.
4751
//
48-
// If kid is non-empty, its quoted value is inserted in the protected head
52+
// The claimset is marshalled using json.Marshal unless it is a string.
53+
// In which case it is inserted directly into the message.
54+
//
55+
// If kid is non-empty, its quoted value is inserted in the protected header
4956
// as "kid" field value. Otherwise, JWK is computed using jwkEncode and inserted
5057
// as "jwk" field value. The "jwk" and "kid" fields are mutually exclusive.
5158
//
59+
// If nonce is non-empty, its quoted value is inserted in the protected header.
60+
//
5261
// See https://tools.ietf.org/html/rfc7515#section-7.
5362
func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid KeyID, nonce, url string) ([]byte, error) {
5463
if key == nil {
@@ -58,20 +67,36 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid KeyID, nonce, ur
5867
if alg == "" || !sha.Available() {
5968
return nil, ErrUnsupportedKey
6069
}
61-
var phead string
70+
headers := struct {
71+
Alg string `json:"alg"`
72+
KID string `json:"kid,omitempty"`
73+
JWK json.RawMessage `json:"jwk,omitempty"`
74+
Nonce string `json:"nonce,omitempty"`
75+
URL string `json:"url"`
76+
}{
77+
Alg: alg,
78+
Nonce: nonce,
79+
URL: url,
80+
}
6281
switch kid {
6382
case noKeyID:
6483
jwk, err := jwkEncode(key.Public())
6584
if err != nil {
6685
return nil, err
6786
}
68-
phead = fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q,"url":%q}`, alg, jwk, nonce, url)
87+
headers.JWK = json.RawMessage(jwk)
6988
default:
70-
phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, kid, nonce, url)
89+
headers.KID = string(kid)
90+
}
91+
phJSON, err := json.Marshal(headers)
92+
if err != nil {
93+
return nil, err
7194
}
72-
phead = base64.RawURLEncoding.EncodeToString([]byte(phead))
95+
phead := base64.RawURLEncoding.EncodeToString([]byte(phJSON))
7396
var payload string
74-
if claimset != noPayload {
97+
if val, ok := claimset.(string); ok {
98+
payload = val
99+
} else {
75100
cs, err := json.Marshal(claimset)
76101
if err != nil {
77102
return nil, err

acme/jws_test.go

+38
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,44 @@ func TestJWSEncodeJSON(t *testing.T) {
195195
}
196196
}
197197

198+
func TestJWSEncodeNoNonce(t *testing.T) {
199+
kid := KeyID("https://example.org/account/1")
200+
claims := "RawString"
201+
const (
202+
// {"alg":"ES256","kid":"https://example.org/account/1","nonce":"nonce","url":"url"}
203+
protected = "eyJhbGciOiJFUzI1NiIsImtpZCI6Imh0dHBzOi8vZXhhbXBsZS5vcmcvYWNjb3VudC8xIiwidXJsIjoidXJsIn0"
204+
// "Raw String"
205+
payload = "RawString"
206+
)
207+
208+
b, err := jwsEncodeJSON(claims, testKeyEC, kid, "", "url")
209+
if err != nil {
210+
t.Fatal(err)
211+
}
212+
var jws struct{ Protected, Payload, Signature string }
213+
if err := json.Unmarshal(b, &jws); err != nil {
214+
t.Fatal(err)
215+
}
216+
if jws.Protected != protected {
217+
t.Errorf("protected:\n%s\nwant:\n%s", jws.Protected, protected)
218+
}
219+
if jws.Payload != payload {
220+
t.Errorf("payload:\n%s\nwant:\n%s", jws.Payload, payload)
221+
}
222+
223+
sig, err := base64.RawURLEncoding.DecodeString(jws.Signature)
224+
if err != nil {
225+
t.Fatalf("jws.Signature: %v", err)
226+
}
227+
r, s := big.NewInt(0), big.NewInt(0)
228+
r.SetBytes(sig[:len(sig)/2])
229+
s.SetBytes(sig[len(sig)/2:])
230+
h := sha256.Sum256([]byte(protected + "." + payload))
231+
if !ecdsa.Verify(testKeyEC.Public().(*ecdsa.PublicKey), h[:], r, s) {
232+
t.Error("invalid signature")
233+
}
234+
}
235+
198236
func TestJWSEncodeKID(t *testing.T) {
199237
kid := KeyID("https://example.org/account/1")
200238
claims := struct{ Msg string }{"Hello JWS"}

acme/rfc8555.go

+36
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,42 @@ func responseAccount(res *http.Response) (*Account, error) {
148148
}, nil
149149
}
150150

151+
// accountKeyRollover attempts to perform account key rollover.
152+
// On success it will change client.Key to the new key.
153+
func (c *Client) accountKeyRollover(ctx context.Context, newKey crypto.Signer) error {
154+
dir, err := c.Discover(ctx) // Also required by c.accountKID
155+
if err != nil {
156+
return err
157+
}
158+
kid := c.accountKID(ctx)
159+
if kid == noKeyID {
160+
return ErrNoAccount
161+
}
162+
oldKey, err := jwkEncode(c.Key.Public())
163+
if err != nil {
164+
return err
165+
}
166+
payload := struct {
167+
Account string `json:"account"`
168+
OldKey json.RawMessage `json:"oldKey"`
169+
}{
170+
Account: string(kid),
171+
OldKey: json.RawMessage(oldKey),
172+
}
173+
inner, err := jwsEncodeJSON(payload, newKey, noKeyID, noNonce, dir.KeyChangeURL)
174+
if err != nil {
175+
return err
176+
}
177+
178+
res, err := c.post(ctx, nil, dir.KeyChangeURL, base64.RawURLEncoding.EncodeToString(inner), wantStatus(http.StatusOK))
179+
if err != nil {
180+
return err
181+
}
182+
defer res.Body.Close()
183+
c.Key = newKey
184+
return nil
185+
}
186+
151187
// AuthorizeOrder initiates the order-based application for certificate issuance,
152188
// as opposed to pre-authorization in Authorize.
153189
// It is only supported by CAs implementing RFC 8555.

acme/rfc8555_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,15 @@ func (s *acmeServer) start() {
232232
"newOrder": %q,
233233
"newAuthz": %q,
234234
"revokeCert": %q,
235+
"keyChange": %q,
235236
"meta": {"termsOfService": %q}
236237
}`,
237238
s.url("/acme/new-nonce"),
238239
s.url("/acme/new-account"),
239240
s.url("/acme/new-order"),
240241
s.url("/acme/new-authz"),
241242
s.url("/acme/revoke-cert"),
243+
s.url("/acme/key-change"),
242244
s.url("/terms"),
243245
)
244246
return
@@ -621,6 +623,27 @@ func TestRFC_GetRegOtherError(t *testing.T) {
621623
}
622624
}
623625

626+
func TestRFC_AccountKeyRollover(t *testing.T) {
627+
s := newACMEServer()
628+
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
629+
w.Header().Set("Location", s.url("/accounts/1"))
630+
w.WriteHeader(http.StatusOK)
631+
w.Write([]byte(`{"status": "valid"}`))
632+
})
633+
s.handle("/acme/key-change", func(w http.ResponseWriter, r *http.Request) {
634+
w.WriteHeader(http.StatusOK)
635+
})
636+
s.start()
637+
defer s.close()
638+
639+
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
640+
if err := cl.AccountKeyRollover(context.Background(), testKeyEC384); err != nil {
641+
t.Errorf("AccountKeyRollover: %v, wanted no error", err)
642+
} else if cl.Key != testKeyEC384 {
643+
t.Error("AccountKeyRollover did not rotate the client key")
644+
}
645+
}
646+
624647
func TestRFC_AuthorizeOrder(t *testing.T) {
625648
s := newACMEServer()
626649
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)