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: 8e8cc5b094743262939c145f56d3a3b57a057d64
GitHub-Pull-Request: golang/crypto#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>
This commit is contained in:
Jason Baker
2022-05-16 16:29:34 +00:00
committed by Roland Shoemaker
parent 46612604a0
commit 403b01795a
5 changed files with 142 additions and 6 deletions
+14
View File
@@ -306,6 +306,20 @@ func (c *Client) UpdateReg(ctx context.Context, acct *Account) (*Account, error)
return c.updateRegRFC(ctx, acct)
}
// AccountKeyRollover attempts to transition a client's account key to a new key.
// On success client's Key is updated which is not concurrency safe.
// On failure an error will be returned.
// The new key is already registered with the ACME provider if the following is true:
// - error is of type acme.Error
// - StatusCode should be 409 (Conflict)
// - Location header will have the KID of the associated account
//
// More about account key rollover can be found at
// https://tools.ietf.org/html/rfc8555#section-7.3.5.
func (c *Client) AccountKeyRollover(ctx context.Context, newKey crypto.Signer) error {
return c.accountKeyRollover(ctx, newKey)
}
// Authorize performs the initial step in the pre-authorization flow,
// as opposed to order-based flow.
// The caller will then need to choose from and perform a set of returned
+31 -6
View File
@@ -33,6 +33,10 @@ const noKeyID = KeyID("")
// See https://tools.ietf.org/html/rfc8555#section-6.3 for more details.
const noPayload = ""
// noNonce indicates that the nonce should be omitted from the protected header.
// See jwsEncodeJSON for details.
const noNonce = ""
// jsonWebSignature can be easily serialized into a JWS following
// https://tools.ietf.org/html/rfc7515#section-3.2.
type jsonWebSignature struct {
@@ -45,10 +49,15 @@ type jsonWebSignature struct {
// The result is serialized in JSON format containing either kid or jwk
// fields based on the provided KeyID value.
//
// If kid is non-empty, its quoted value is inserted in the protected head
// The claimset is marshalled using json.Marshal unless it is a string.
// In which case it is inserted directly into the message.
//
// If kid is non-empty, its quoted value is inserted in the protected header
// as "kid" field value. Otherwise, JWK is computed using jwkEncode and inserted
// as "jwk" field value. The "jwk" and "kid" fields are mutually exclusive.
//
// If nonce is non-empty, its quoted value is inserted in the protected header.
//
// See https://tools.ietf.org/html/rfc7515#section-7.
func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid KeyID, nonce, url string) ([]byte, error) {
if key == nil {
@@ -58,20 +67,36 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid KeyID, nonce, ur
if alg == "" || !sha.Available() {
return nil, ErrUnsupportedKey
}
var phead string
headers := struct {
Alg string `json:"alg"`
KID string `json:"kid,omitempty"`
JWK json.RawMessage `json:"jwk,omitempty"`
Nonce string `json:"nonce,omitempty"`
URL string `json:"url"`
}{
Alg: alg,
Nonce: nonce,
URL: url,
}
switch kid {
case noKeyID:
jwk, err := jwkEncode(key.Public())
if err != nil {
return nil, err
}
phead = fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q,"url":%q}`, alg, jwk, nonce, url)
headers.JWK = json.RawMessage(jwk)
default:
phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, kid, nonce, url)
headers.KID = string(kid)
}
phead = base64.RawURLEncoding.EncodeToString([]byte(phead))
phJSON, err := json.Marshal(headers)
if err != nil {
return nil, err
}
phead := base64.RawURLEncoding.EncodeToString([]byte(phJSON))
var payload string
if claimset != noPayload {
if val, ok := claimset.(string); ok {
payload = val
} else {
cs, err := json.Marshal(claimset)
if err != nil {
return nil, err
+38
View File
@@ -195,6 +195,44 @@ func TestJWSEncodeJSON(t *testing.T) {
}
}
func TestJWSEncodeNoNonce(t *testing.T) {
kid := KeyID("https://example.org/account/1")
claims := "RawString"
const (
// {"alg":"ES256","kid":"https://example.org/account/1","nonce":"nonce","url":"url"}
protected = "eyJhbGciOiJFUzI1NiIsImtpZCI6Imh0dHBzOi8vZXhhbXBsZS5vcmcvYWNjb3VudC8xIiwidXJsIjoidXJsIn0"
// "Raw String"
payload = "RawString"
)
b, err := jwsEncodeJSON(claims, testKeyEC, kid, "", "url")
if err != nil {
t.Fatal(err)
}
var jws struct{ Protected, Payload, Signature string }
if err := json.Unmarshal(b, &jws); err != nil {
t.Fatal(err)
}
if jws.Protected != protected {
t.Errorf("protected:\n%s\nwant:\n%s", jws.Protected, protected)
}
if jws.Payload != payload {
t.Errorf("payload:\n%s\nwant:\n%s", jws.Payload, payload)
}
sig, err := base64.RawURLEncoding.DecodeString(jws.Signature)
if err != nil {
t.Fatalf("jws.Signature: %v", err)
}
r, s := big.NewInt(0), big.NewInt(0)
r.SetBytes(sig[:len(sig)/2])
s.SetBytes(sig[len(sig)/2:])
h := sha256.Sum256([]byte(protected + "." + payload))
if !ecdsa.Verify(testKeyEC.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("invalid signature")
}
}
func TestJWSEncodeKID(t *testing.T) {
kid := KeyID("https://example.org/account/1")
claims := struct{ Msg string }{"Hello JWS"}
+36
View File
@@ -148,6 +148,42 @@ func responseAccount(res *http.Response) (*Account, error) {
}, nil
}
// accountKeyRollover attempts to perform account key rollover.
// On success it will change client.Key to the new key.
func (c *Client) accountKeyRollover(ctx context.Context, newKey crypto.Signer) error {
dir, err := c.Discover(ctx) // Also required by c.accountKID
if err != nil {
return err
}
kid := c.accountKID(ctx)
if kid == noKeyID {
return ErrNoAccount
}
oldKey, err := jwkEncode(c.Key.Public())
if err != nil {
return err
}
payload := struct {
Account string `json:"account"`
OldKey json.RawMessage `json:"oldKey"`
}{
Account: string(kid),
OldKey: json.RawMessage(oldKey),
}
inner, err := jwsEncodeJSON(payload, newKey, noKeyID, noNonce, dir.KeyChangeURL)
if err != nil {
return err
}
res, err := c.post(ctx, nil, dir.KeyChangeURL, base64.RawURLEncoding.EncodeToString(inner), wantStatus(http.StatusOK))
if err != nil {
return err
}
defer res.Body.Close()
c.Key = newKey
return nil
}
// AuthorizeOrder initiates the order-based application for certificate issuance,
// as opposed to pre-authorization in Authorize.
// It is only supported by CAs implementing RFC 8555.
+23
View File
@@ -232,6 +232,7 @@ func (s *acmeServer) start() {
"newOrder": %q,
"newAuthz": %q,
"revokeCert": %q,
"keyChange": %q,
"meta": {"termsOfService": %q}
}`,
s.url("/acme/new-nonce"),
@@ -239,6 +240,7 @@ func (s *acmeServer) start() {
s.url("/acme/new-order"),
s.url("/acme/new-authz"),
s.url("/acme/revoke-cert"),
s.url("/acme/key-change"),
s.url("/terms"),
)
return
@@ -621,6 +623,27 @@ func TestRFC_GetRegOtherError(t *testing.T) {
}
}
func TestRFC_AccountKeyRollover(t *testing.T) {
s := newACMEServer()
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", s.url("/accounts/1"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "valid"}`))
})
s.handle("/acme/key-change", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
s.start()
defer s.close()
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
if err := cl.AccountKeyRollover(context.Background(), testKeyEC384); err != nil {
t.Errorf("AccountKeyRollover: %v, wanted no error", err)
} else if cl.Key != testKeyEC384 {
t.Error("AccountKeyRollover did not rotate the client key")
}
}
func TestRFC_AuthorizeOrder(t *testing.T) {
s := newACMEServer()
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {