mirror of
https://github.com/go-acme/lego
synced 2026-03-14 14:35:48 +01:00
feat(lib): add Account Key Rollover support (#2887)
This commit is contained in:
parent
7356771e1e
commit
099d236fc6
8 changed files with 273 additions and 67 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
100
acme/api/internal/secure/jws_test.go
Normal file
100
acme/api/internal/secure/jws_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue