From cf186a4120195c5db3e0a76c3f8e320a0e4a04fa Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 1 Oct 2025 15:14:29 +0200 Subject: [PATCH] Add support for persistent ACME account keys Allow ACME account keys to be configured via config file to maintain the same Let's Encrypt account across deployments. Add DecodePrivateKey function with PEM block type constants to support EC, RSA, and PKCS8 key formats. When no account key is provided, fall back to generating a new one with a warning. Signed-off-by: Bryan Frimin --- pkg/certmanager/acme.go | 22 +++++++++++---- pkg/crypto/pem/pem.go | 43 +++++++++++++++++++++++++---- pkg/probod/custom_domains_config.go | 1 + pkg/probod/probod.go | 29 ++++++++++++------- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/pkg/certmanager/acme.go b/pkg/certmanager/acme.go index 9475077cc..7708d2473 100644 --- a/pkg/certmanager/acme.go +++ b/pkg/certmanager/acme.go @@ -62,10 +62,22 @@ type ( // completed before the certificate can be issued or renewed. var ErrHTTPChallengeRequired = errors.New("HTTP challenge required") -func NewACMEService(email string, keyType keys.Type, directoryURL string, insecureTLS bool, logger *log.Logger) (*ACMEService, error) { - accountKey, err := keys.Generate(keyType) - if err != nil { - return nil, fmt.Errorf("cannot generate account key: %w", err) +func NewACMEService( + email string, + keyType keys.Type, + directoryURL string, + insecureTLS bool, + accountKey crypto.Signer, + logger *log.Logger, +) (*ACMEService, error) { + if accountKey == nil { + var err error + accountKey, err = keys.Generate(keyType) + if err != nil { + return nil, fmt.Errorf("cannot generate account key: %w", err) + } + + logger.Warn("no account key provided, generating new ACME account - this will create a new account on each restart") } var httpClient *http.Client @@ -78,6 +90,7 @@ func NewACMEService(email string, keyType keys.Type, directoryURL string, insecu Transport: transport, Timeout: 30 * time.Second, } + logger.Warn("ACME service configured with insecure TLS - use only for local testing") } else { httpClient = httpclient.DefaultPooledClient( @@ -225,7 +238,6 @@ func (s *ACMEService) ObtainCertificate( ctx context.Context, domain string, ) (*Certificate, error) { - // For HTTP-01, we always need to serve the challenge challenge, err := s.GetHTTPChallenge(ctx, domain) if err != nil { return nil, fmt.Errorf("cannot get HTTP challenge: %w", err) diff --git a/pkg/crypto/pem/pem.go b/pkg/crypto/pem/pem.go index 55dfc0231..9123b375a 100644 --- a/pkg/crypto/pem/pem.go +++ b/pkg/crypto/pem/pem.go @@ -12,7 +12,6 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -// Package pem provides utilities for encoding certificates and keys in PEM format package pem import ( @@ -25,15 +24,21 @@ import ( "fmt" ) +const ( + BlockTypeCertificate = "CERTIFICATE" + BlockTypeECPrivateKey = "EC PRIVATE KEY" + BlockTypeRSAPrivateKey = "RSA PRIVATE KEY" + BlockTypePKCS8PrivateKey = "PRIVATE KEY" +) + func EncodeCertificate(der []byte) []byte { block := &pem.Block{ - Type: "CERTIFICATE", + Type: BlockTypeCertificate, Bytes: der, } return pem.EncodeToMemory(block) } -// EncodeCertificateChain encodes multiple DER-encoded certificates into a single PEM chain func EncodeCertificateChain(derCerts [][]byte) []byte { var chain []byte for _, der := range derCerts { @@ -53,17 +58,17 @@ func EncodePrivateKey(key crypto.Signer) ([]byte, error) { return nil, fmt.Errorf("cannot marshal EC private key: %w", err) } keyDER = der - keyType = "EC PRIVATE KEY" + keyType = BlockTypeECPrivateKey case *rsa.PrivateKey: keyDER = x509.MarshalPKCS1PrivateKey(k) - keyType = "RSA PRIVATE KEY" + keyType = BlockTypeRSAPrivateKey case ed25519.PrivateKey: der, err := x509.MarshalPKCS8PrivateKey(k) if err != nil { return nil, fmt.Errorf("cannot marshal ED25519 private key: %w", err) } keyDER = der - keyType = "PRIVATE KEY" + keyType = BlockTypePKCS8PrivateKey default: return nil, fmt.Errorf("unsupported key type: %T", key) } @@ -75,3 +80,29 @@ func EncodePrivateKey(key crypto.Signer) ([]byte, error) { return pem.EncodeToMemory(block), nil } + +func DecodePrivateKey(pemData []byte) (crypto.Signer, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("cannot to decode PEM block") + } + + switch block.Type { + case BlockTypeECPrivateKey: + return x509.ParseECPrivateKey(block.Bytes) + case BlockTypeRSAPrivateKey: + return x509.ParsePKCS1PrivateKey(block.Bytes) + case BlockTypePKCS8PrivateKey: + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("cannot parse PKCS8 private key: %w", err) + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("key is not a crypto.Signer") + } + return signer, nil + default: + return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type) + } +} diff --git a/pkg/probod/custom_domains_config.go b/pkg/probod/custom_domains_config.go index 1f493fc88..09cc591bd 100644 --- a/pkg/probod/custom_domains_config.go +++ b/pkg/probod/custom_domains_config.go @@ -26,4 +26,5 @@ type acmeConfig struct { Email string `json:"email"` KeyType string `json:"key-type"` InsecureTLS bool `json:"insecure-tls"` + AccountKey string `json:"account-key"` } diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d1df5b427..cf40f4d0e 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -16,6 +16,7 @@ package probod import ( "context" + "crypto" "crypto/tls" "errors" "fmt" @@ -33,6 +34,7 @@ import ( "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/crypto/keys" "github.com/getprobo/probo/pkg/crypto/passwdhash" + "github.com/getprobo/probo/pkg/crypto/pem" "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/mailer" "github.com/getprobo/probo/pkg/probo" @@ -256,18 +258,25 @@ func (impl *Implm) Run( return fmt.Errorf("cannot create usrmgr service: %w", err) } - var acmeService *certmanager.ACMEService - if impl.cfg.CustomDomains.ACME.Directory != "" { - acmeService, err = certmanager.NewACMEService( - impl.cfg.CustomDomains.ACME.Email, - keys.Type(impl.cfg.CustomDomains.ACME.KeyType), - impl.cfg.CustomDomains.ACME.Directory, - impl.cfg.CustomDomains.ACME.InsecureTLS, - l, - ) + var accountKey crypto.Signer + if impl.cfg.CustomDomains.ACME.AccountKey != "" { + accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey)) if err != nil { - return fmt.Errorf("failed to initialize ACME service: %w", err) + return fmt.Errorf("failed to decode ACME account key: %w", err) } + l.Info("using configured ACME account key") + } + + acmeService, err := certmanager.NewACMEService( + impl.cfg.CustomDomains.ACME.Email, + keys.Type(impl.cfg.CustomDomains.ACME.KeyType), + impl.cfg.CustomDomains.ACME.Directory, + impl.cfg.CustomDomains.ACME.InsecureTLS, + accountKey, + l, + ) + if err != nil { + return fmt.Errorf("failed to initialize ACME service: %w", err) } proboService, err := probo.NewService(