From 099d236fc6a44301eb473455e52cf2457d567932 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Wed, 4 Mar 2026 21:07:08 +0100 Subject: [PATCH] feat(lib): add Account Key Rollover support (#2887) --- acme/api/account.go | 28 ++++- acme/api/api.go | 13 +- acme/api/internal/nonces/nonce_manager.go | 4 + acme/api/internal/secure/jws.go | 147 ++++++++++++++-------- acme/api/internal/secure/jws_test.go | 100 +++++++++++++++ acme/commons.go | 7 ++ e2e/challenges_test.go | 35 ++++++ registration/registar.go | 6 + 8 files changed, 273 insertions(+), 67 deletions(-) create mode 100644 acme/api/internal/secure/jws_test.go diff --git a/acme/api/account.go b/acme/api/account.go index 4d37267cc..09205622b 100644 --- a/acme/api/account.go +++ b/acme/api/account.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto" "encoding/base64" "errors" "fmt" @@ -29,20 +30,20 @@ func (a *AccountService) New(ctx context.Context, req acme.Account) (acme.Extend } // NewEAB Creates a new account with an External Account Binding. -func (a *AccountService) NewEAB(ctx context.Context, accMsg acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) { +func (a *AccountService) NewEAB(ctx context.Context, req acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) { hmac, err := decodeEABHmac(hmacEncoded) if err != nil { return acme.ExtendedAccount{}, err } - eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac) + eabJWS, err := a.core.jws().SignEAB(a.core.GetDirectory().NewAccountURL, kid, hmac) if err != nil { return acme.ExtendedAccount{}, fmt.Errorf("acme: error signing eab content: %w", err) } - accMsg.ExternalAccountBinding = eabJWS + req.ExternalAccountBinding = []byte(eabJWS.FullSerialize()) - return a.New(ctx, accMsg) + return a.New(ctx, req) } // Get Retrieves an account. @@ -89,6 +90,25 @@ func (a *AccountService) Deactivate(ctx context.Context, accountURL string) erro return err } +// KeyChange Changes the account key. +func (a *AccountService) KeyChange(ctx context.Context, newKey crypto.PrivateKey) error { + uri := a.core.GetDirectory().KeyChangeURL + + eabJWS, err := a.core.jws().SignKeyChange(uri, newKey) + if err != nil { + return err + } + + _, err = a.core.retrievablePost(ctx, uri, []byte(eabJWS.FullSerialize()), nil) + if err != nil { + return err + } + + a.core.setPrivateKey(newKey) + + return nil +} + func decodeEABHmac(hmacEncoded string) ([]byte, error) { hmac, errRaw := base64.RawURLEncoding.DecodeString(hmacEncoded) if errRaw == nil { diff --git a/acme/api/api.go b/acme/api/api.go index 494f9cc74..0f12d71ce 100644 --- a/acme/api/api.go +++ b/acme/api/api.go @@ -85,6 +85,10 @@ func (a *Core) GetKid() string { return a.kid } +func (a *Core) setPrivateKey(privateKey crypto.PrivateKey) { + a.privateKey = privateKey +} + // post performs an HTTP POST request and parses the response body as JSON, // into the provided respBody object. func (a *Core) post(ctx context.Context, uri string, reqBody, response any) (*http.Response, error) { @@ -152,15 +156,6 @@ func (a *Core) signedPost(ctx context.Context, uri string, content []byte, respo return resp, err } -func (a *Core) signEABContent(newAccountURL, kid string, hmac []byte) ([]byte, error) { - eabJWS, err := a.jws().SignEABContent(newAccountURL, kid, hmac) - if err != nil { - return nil, err - } - - return []byte(eabJWS.FullSerialize()), nil -} - // GetKeyAuthorization Gets the key authorization. func (a *Core) GetKeyAuthorization(token string) (string, error) { return a.jws().GetKeyAuthorization(token) diff --git a/acme/api/internal/nonces/nonce_manager.go b/acme/api/internal/nonces/nonce_manager.go index f2d0c5ca7..1d2447c60 100644 --- a/acme/api/internal/nonces/nonce_manager.go +++ b/acme/api/internal/nonces/nonce_manager.go @@ -78,6 +78,10 @@ func GetFromResponse(resp *http.Response) (string, error) { return nonce, nil } +func (n *Manager) NewNonceSource(ctx context.Context) jose.NonceSource { + return NewNonceSource(ctx, n) +} + var _ jose.NonceSource = (*NonceSource)(nil) // NonceSource implements [jose.NonceSource]. diff --git a/acme/api/internal/secure/jws.go b/acme/api/internal/secure/jws.go index b97f3a766..35e6b97ce 100644 --- a/acme/api/internal/secure/jws.go +++ b/acme/api/internal/secure/jws.go @@ -7,21 +7,27 @@ import ( "crypto/elliptic" "crypto/rsa" "encoding/base64" + "encoding/json" + "errors" "fmt" - "github.com/go-acme/lego/v5/acme/api/internal/nonces" + "github.com/go-acme/lego/v5/acme" "github.com/go-jose/go-jose/v4" ) +type nonceSourceCreator interface { + NewNonceSource(ctx context.Context) jose.NonceSource +} + // JWS Represents a JWS. type JWS struct { privKey crypto.PrivateKey kid string // Key identifier - nonces *nonces.Manager + nonces nonceSourceCreator } // NewJWS Create a new JWS. -func NewJWS(privateKey crypto.PrivateKey, kid string, nonceManager *nonces.Manager) *JWS { +func NewJWS(privateKey crypto.PrivateKey, kid string, nonceManager nonceSourceCreator) *JWS { return &JWS{ privKey: privateKey, nonces: nonceManager, @@ -31,50 +37,24 @@ func NewJWS(privateKey crypto.PrivateKey, kid string, nonceManager *nonces.Manag // SignContent Signs a content with the JWS. func (j *JWS) SignContent(ctx context.Context, url string, content []byte) (*jose.JSONWebSignature, error) { - var alg jose.SignatureAlgorithm - - switch k := j.privKey.(type) { - case *rsa.PrivateKey: - alg = jose.RS256 - case *ecdsa.PrivateKey: - if k.Curve == elliptic.P256() { - alg = jose.ES256 - } else if k.Curve == elliptic.P384() { - alg = jose.ES384 - } - } - signKey := jose.SigningKey{ - Algorithm: alg, + Algorithm: signatureAlgorithm(j.privKey), Key: jose.JSONWebKey{Key: j.privKey, KeyID: j.kid}, } - options := jose.SignerOptions{ - NonceSource: nonces.NewNonceSource(ctx, j.nonces), + options := &jose.SignerOptions{ + NonceSource: j.nonces.NewNonceSource(ctx), ExtraHeaders: map[jose.HeaderKey]any{ "url": url, }, + EmbedJWK: j.kid == "", } - if j.kid == "" { - options.EmbedJWK = true - } - - signer, err := jose.NewSigner(signKey, &options) - if err != nil { - return nil, fmt.Errorf("failed to create jose signer: %w", err) - } - - signed, err := signer.Sign(content) - if err != nil { - return nil, fmt.Errorf("failed to sign content: %w", err) - } - - return signed, nil + return sign(content, signKey, options) } -// SignEABContent Signs an external account binding content with the JWS. -func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) { +// SignEAB Signs an external account binding with the JWS. +func (j *JWS) SignEAB(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) { jwk := jose.JSONWebKey{Key: j.privKey} jwkJSON, err := jwk.Public().MarshalJSON() @@ -82,23 +62,19 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu return nil, fmt.Errorf("acme: error encoding eab jwk key: %w", err) } - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: hmac}, - &jose.SignerOptions{ - EmbedJWK: false, - ExtraHeaders: map[jose.HeaderKey]any{ - "kid": kid, - "url": url, - }, + signKey := jose.SigningKey{Algorithm: jose.HS256, Key: hmac} + + options := &jose.SignerOptions{ + EmbedJWK: false, + ExtraHeaders: map[jose.HeaderKey]any{ + "kid": kid, + "url": url, }, - ) - if err != nil { - return nil, fmt.Errorf("failed to create External Account Binding jose signer: %w", err) } - signed, err := signer.Sign(jwkJSON) + signed, err := sign(jwkJSON, signKey, options) if err != nil { - return nil, fmt.Errorf("failed to External Account Binding sign content: %w", err) + return nil, fmt.Errorf("EAB: %w", err) } return signed, nil @@ -108,11 +84,9 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu func (j *JWS) GetKeyAuthorization(token string) (string, error) { var publicKey crypto.PublicKey - switch k := j.privKey.(type) { - case *ecdsa.PrivateKey: - publicKey = k.Public() - case *rsa.PrivateKey: - publicKey = k.Public() + signer, ok := j.privKey.(crypto.Signer) + if ok { + publicKey = signer.Public() } // Generate the Key Authorization for the challenge @@ -128,3 +102,68 @@ func (j *JWS) GetKeyAuthorization(token string) (string, error) { return token + "." + keyThumb, nil } + +func (j *JWS) SignKeyChange(url string, newKey crypto.PrivateKey) (*jose.JSONWebSignature, error) { + if j.kid == "" { + return nil, errors.New("missing kid") + } + + oldKeyJWS := jose.JSONWebKey{Key: j.privKey} + + oldKeyJSON, err := oldKeyJWS.Public().MarshalJSON() + if err != nil { + return nil, err + } + + kc := acme.KeyChange{ + Account: j.kid, + OldKey: oldKeyJSON, + } + + signKey := jose.SigningKey{Algorithm: signatureAlgorithm(newKey), Key: newKey} + + options := &jose.SignerOptions{ + EmbedJWK: true, + ExtraHeaders: map[jose.HeaderKey]any{ + "url": url, + }, + } + + kcJSON, err := json.Marshal(kc) + if err != nil { + return nil, err + } + + return sign(kcJSON, signKey, options) +} + +func sign(content []byte, signKey jose.SigningKey, options *jose.SignerOptions) (*jose.JSONWebSignature, error) { + signer, err := jose.NewSigner(signKey, options) + if err != nil { + return nil, fmt.Errorf("new jose signer: %w", err) + } + + signed, err := signer.Sign(content) + if err != nil { + return nil, fmt.Errorf("sign content: %w", err) + } + + return signed, nil +} + +func signatureAlgorithm(privKey crypto.PrivateKey) jose.SignatureAlgorithm { + var alg jose.SignatureAlgorithm + + switch k := privKey.(type) { + case *rsa.PrivateKey: + alg = jose.RS256 + case *ecdsa.PrivateKey: + if k.Curve == elliptic.P256() { + alg = jose.ES256 + } else if k.Curve == elliptic.P384() { + alg = jose.ES384 + } + } + + return alg +} diff --git a/acme/api/internal/secure/jws_test.go b/acme/api/internal/secure/jws_test.go new file mode 100644 index 000000000..155a1fe91 --- /dev/null +++ b/acme/api/internal/secure/jws_test.go @@ -0,0 +1,100 @@ +package secure + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type MockNonceManager struct{} + +func (f *MockNonceManager) NewNonceSource(ctx context.Context) jose.NonceSource { + return &MockNonceSource{} +} + +type MockNonceSource struct{} + +func (b *MockNonceSource) Nonce() (string, error) { + return "xxxNoncexxx", nil +} + +func TestJWS_SignContent(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + jws := NewJWS(privKey, "https://example.com", &MockNonceManager{}) + + content, err := jws.SignContent(t.Context(), "https://foo.example", []byte("{}")) + require.NoError(t, err) + + check(t, content) +} + +func TestJWS_SignEAB(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + jws := NewJWS(privKey, "https://example.com", &MockNonceManager{}) + + content, err := jws.SignEAB("https://foo.example/a", "https://foo.example/b", x509.MarshalPKCS1PrivateKey(privKey)) + require.NoError(t, err) + + check(t, content) +} + +func TestJWS_SignKeyChange(t *testing.T) { + const ( + kid = "https://example.com/acme/acct/evOfKhNU60wg" + endpoint = "https://example.com/acme/key-change" + ) + + oldKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + jws := NewJWS(oldKey, kid, &MockNonceManager{}) + + newKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + + content, err := jws.SignKeyChange(endpoint, newKey) + require.NoError(t, err) + + check(t, content) +} + +func check(t *testing.T, content *jose.JSONWebSignature) { + t.Helper() + + serialized := content.FullSerialize() + + var data map[string]any + + err := json.Unmarshal([]byte(serialized), &data) + require.NoError(t, err) + + assert.Len(t, data, 3) + + assert.Contains(t, data, "protected") + assert.Contains(t, data, "payload") + assert.Contains(t, data, "signature") + + dec, err := base64.RawStdEncoding.DecodeString(data["protected"].(string)) + require.NoError(t, err) + + t.Log("protected:", string(dec)) + + dec, err = base64.RawStdEncoding.DecodeString(data["payload"].(string)) + require.NoError(t, err) + + t.Log("payload:", string(dec)) +} diff --git a/acme/commons.go b/acme/commons.go index a874a6d3d..acf955939 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -377,3 +377,10 @@ type RenewalInfo struct { // Callers SHOULD provide this URL to their operator, if present. ExplanationURL string `json:"explanationURL"` } + +// KeyChange is the response to POST requests made the keyChange endpoint. +// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.5 +type KeyChange struct { + Account string `json:"account"` + OldKey json.RawMessage `json:"oldKey"` +} diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index 0e4e698ae..7f9c3f57e 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -2,6 +2,8 @@ package e2e import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/pem" @@ -93,6 +95,39 @@ func TestRegistrar_UpdateAccount(t *testing.T) { require.Equal(t, reg.Location, resource.Location) } +func TestRegistrar_KeyRollover(t *testing.T) { + err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() + + oldKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + user := &internal.FakeUser{ + PrivateKey: oldKey, + Email: testEmail1, + } + + config := lego.NewConfig(user) + config.CADirURL = load.PebbleOptions.HealthCheckURL + + client, err := lego.NewClient(config) + require.NoError(t, err) + + ctx := t.Context() + + regOptions := registration.RegisterOptions{TermsOfServiceAgreed: true} + _, err = client.Registration.Register(ctx, regOptions) + require.NoError(t, err) + + newKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + + err = client.Registration.KeyRollover(ctx, newKey) + require.NoError(t, err) +} + func createTestCSRFile(t *testing.T, raw bool) string { t.Helper() diff --git a/registration/registar.go b/registration/registar.go index daf25c3ea..40a4653eb 100644 --- a/registration/registar.go +++ b/registration/registar.go @@ -2,6 +2,7 @@ package registration import ( "context" + "crypto" "errors" "fmt" "log/slog" @@ -169,6 +170,11 @@ func (r *Registrar) ResolveAccountByKey(ctx context.Context) (*acme.ExtendedAcco return &account, nil } +// KeyRollover will attempt to change the account key. +func (r *Registrar) KeyRollover(ctx context.Context, newKey crypto.PrivateKey) error { + return r.core.Accounts.KeyChange(ctx, newKey) +} + // RegisterWithZeroSSL registers the current account to the ZeroSSL. // It uses either an access key or an email to generate an EAB. func RegisterWithZeroSSL(ctx context.Context, r *Registrar, email string) (*acme.ExtendedAccount, error) {