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 <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-10-01 15:14:29 +02:00
parent ea525ac1a7
commit cf186a4120
4 changed files with 74 additions and 21 deletions

View File

@@ -62,10 +62,22 @@ type (
// completed before the certificate can be issued or renewed. // completed before the certificate can be issued or renewed.
var ErrHTTPChallengeRequired = errors.New("HTTP challenge required") var ErrHTTPChallengeRequired = errors.New("HTTP challenge required")
func NewACMEService(email string, keyType keys.Type, directoryURL string, insecureTLS bool, logger *log.Logger) (*ACMEService, error) { func NewACMEService(
accountKey, err := keys.Generate(keyType) email string,
if err != nil { keyType keys.Type,
return nil, fmt.Errorf("cannot generate account key: %w", err) 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 var httpClient *http.Client
@@ -78,6 +90,7 @@ func NewACMEService(email string, keyType keys.Type, directoryURL string, insecu
Transport: transport, Transport: transport,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
} }
logger.Warn("ACME service configured with insecure TLS - use only for local testing") logger.Warn("ACME service configured with insecure TLS - use only for local testing")
} else { } else {
httpClient = httpclient.DefaultPooledClient( httpClient = httpclient.DefaultPooledClient(
@@ -225,7 +238,6 @@ func (s *ACMEService) ObtainCertificate(
ctx context.Context, ctx context.Context,
domain string, domain string,
) (*Certificate, error) { ) (*Certificate, error) {
// For HTTP-01, we always need to serve the challenge
challenge, err := s.GetHTTPChallenge(ctx, domain) challenge, err := s.GetHTTPChallenge(ctx, domain)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot get HTTP challenge: %w", err) return nil, fmt.Errorf("cannot get HTTP challenge: %w", err)

View File

@@ -12,7 +12,6 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
// Package pem provides utilities for encoding certificates and keys in PEM format
package pem package pem
import ( import (
@@ -25,15 +24,21 @@ import (
"fmt" "fmt"
) )
const (
BlockTypeCertificate = "CERTIFICATE"
BlockTypeECPrivateKey = "EC PRIVATE KEY"
BlockTypeRSAPrivateKey = "RSA PRIVATE KEY"
BlockTypePKCS8PrivateKey = "PRIVATE KEY"
)
func EncodeCertificate(der []byte) []byte { func EncodeCertificate(der []byte) []byte {
block := &pem.Block{ block := &pem.Block{
Type: "CERTIFICATE", Type: BlockTypeCertificate,
Bytes: der, Bytes: der,
} }
return pem.EncodeToMemory(block) return pem.EncodeToMemory(block)
} }
// EncodeCertificateChain encodes multiple DER-encoded certificates into a single PEM chain
func EncodeCertificateChain(derCerts [][]byte) []byte { func EncodeCertificateChain(derCerts [][]byte) []byte {
var chain []byte var chain []byte
for _, der := range derCerts { 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) return nil, fmt.Errorf("cannot marshal EC private key: %w", err)
} }
keyDER = der keyDER = der
keyType = "EC PRIVATE KEY" keyType = BlockTypeECPrivateKey
case *rsa.PrivateKey: case *rsa.PrivateKey:
keyDER = x509.MarshalPKCS1PrivateKey(k) keyDER = x509.MarshalPKCS1PrivateKey(k)
keyType = "RSA PRIVATE KEY" keyType = BlockTypeRSAPrivateKey
case ed25519.PrivateKey: case ed25519.PrivateKey:
der, err := x509.MarshalPKCS8PrivateKey(k) der, err := x509.MarshalPKCS8PrivateKey(k)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot marshal ED25519 private key: %w", err) return nil, fmt.Errorf("cannot marshal ED25519 private key: %w", err)
} }
keyDER = der keyDER = der
keyType = "PRIVATE KEY" keyType = BlockTypePKCS8PrivateKey
default: default:
return nil, fmt.Errorf("unsupported key type: %T", key) 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 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)
}
}

View File

@@ -26,4 +26,5 @@ type acmeConfig struct {
Email string `json:"email"` Email string `json:"email"`
KeyType string `json:"key-type"` KeyType string `json:"key-type"`
InsecureTLS bool `json:"insecure-tls"` InsecureTLS bool `json:"insecure-tls"`
AccountKey string `json:"account-key"`
} }

View File

@@ -16,6 +16,7 @@ package probod
import ( import (
"context" "context"
"crypto"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
@@ -33,6 +34,7 @@ import (
"github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/crypto/cipher"
"github.com/getprobo/probo/pkg/crypto/keys" "github.com/getprobo/probo/pkg/crypto/keys"
"github.com/getprobo/probo/pkg/crypto/passwdhash" "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/html2pdf"
"github.com/getprobo/probo/pkg/mailer" "github.com/getprobo/probo/pkg/mailer"
"github.com/getprobo/probo/pkg/probo" "github.com/getprobo/probo/pkg/probo"
@@ -256,18 +258,25 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot create usrmgr service: %w", err) return fmt.Errorf("cannot create usrmgr service: %w", err)
} }
var acmeService *certmanager.ACMEService var accountKey crypto.Signer
if impl.cfg.CustomDomains.ACME.Directory != "" { if impl.cfg.CustomDomains.ACME.AccountKey != "" {
acmeService, err = certmanager.NewACMEService( accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
impl.cfg.CustomDomains.ACME.Email,
keys.Type(impl.cfg.CustomDomains.ACME.KeyType),
impl.cfg.CustomDomains.ACME.Directory,
impl.cfg.CustomDomains.ACME.InsecureTLS,
l,
)
if err != nil { 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( proboService, err := probo.NewService(