feat: key rollover

This commit is contained in:
Fernandez Ludovic 2026-03-04 13:19:22 +01:00
commit 85bf8ba44a
7 changed files with 131 additions and 0 deletions

View file

@ -2,6 +2,7 @@ package api
import (
"context"
"crypto"
"encoding/base64"
"errors"
"fmt"
@ -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 {

View file

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

View file

@ -7,8 +7,11 @@ import (
"crypto/elliptic"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/go-acme/lego/v5/acme"
"github.com/go-jose/go-jose/v4"
)
@ -100,6 +103,40 @@ 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 {

View file

@ -2,6 +2,8 @@ package secure
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@ -50,6 +52,26 @@ func TestJWS_SignEAB(t *testing.T) {
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()

View file

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

View file

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

View file

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