implement ACME external account binding (EAB)

where a new acme account is created with a reference to an existing non-acme
account known by the acme provider. some acme providers require this.
This commit is contained in:
Mechiel Lukkien 2023-12-22 10:34:55 +01:00
parent db3fef4981
commit ee1094e1cb
No known key found for this signature in database
5 changed files with 65 additions and 10 deletions

View file

@ -69,12 +69,15 @@ type Manager struct {
// contactEmail must be a valid email address to which notifications about ACME can // contactEmail must be a valid email address to which notifications about ACME can
// be sent. directoryURL is the ACME starting point. // be sent. directoryURL is the ACME starting point.
// //
// eabKeyID and eabKey are for external account binding when making a new account,
// which some ACME providers require.
//
// getPrivateKey is called to get the private key for the host and key type. It // getPrivateKey is called to get the private key for the host and key type. It
// can be used to deliver a specific (e.g. always the same) private key for a // can be used to deliver a specific (e.g. always the same) private key for a
// host, or a newly generated key. // host, or a newly generated key.
// //
// When shutdown is closed, no new TLS connections can be created. // When shutdown is closed, no new TLS connections can be created.
func Load(name, acmeDir, contactEmail, directoryURL string, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) { func Load(name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eabKey []byte, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) {
if directoryURL == "" { if directoryURL == "" {
return nil, fmt.Errorf("empty ACME directory URL") return nil, fmt.Errorf("empty ACME directory URL")
} }
@ -146,6 +149,14 @@ func Load(name, acmeDir, contactEmail, directoryURL string, getPrivateKey func(h
GetPrivateKey: getPrivateKey, GetPrivateKey: getPrivateKey,
// HostPolicy set below. // HostPolicy set below.
} }
// If external account binding key is provided, use it for registering a new account.
// todo: ideally the key and its id are provided temporarily by the admin when registering a new account. but we don't do that interactive setup yet. in the future, an interactive setup/quickstart would ask for the key once to register a new acme account.
if eabKeyID != "" {
m.ExternalAccountBinding = &acme.ExternalAccountBinding{
KID: eabKeyID,
Key: eabKey,
}
}
loggingGetCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { loggingGetCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
log := mlog.New("autotls", nil).WithContext(hello.Context()) log := mlog.New("autotls", nil).WithContext(hello.Context())

View file

@ -25,7 +25,7 @@ func TestAutotls(t *testing.T) {
getPrivateKey := func(host string, keyType autocert.KeyType) (crypto.Signer, error) { getPrivateKey := func(host string, keyType autocert.KeyType) (crypto.Signer, error) {
return nil, fmt.Errorf("not used") return nil, fmt.Errorf("not used")
} }
m, err := Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", getPrivateKey, shutdown) m, err := Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
if err != nil { if err != nil {
t.Fatalf("load manager: %v", err) t.Fatalf("load manager: %v", err)
} }
@ -82,7 +82,7 @@ func TestAutotls(t *testing.T) {
key0 := m.Manager.Client.Key key0 := m.Manager.Client.Key
m, err = Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", getPrivateKey, shutdown) m, err = Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
if err != nil { if err != nil {
t.Fatalf("load manager again: %v", err) t.Fatalf("load manager again: %v", err)
} }
@ -95,7 +95,7 @@ func TestAutotls(t *testing.T) {
t.Fatalf("hostpolicy, got err %v, expected no error", err) t.Fatalf("hostpolicy, got err %v, expected no error", err)
} }
m2, err := Load("test2", "../testdata/autotls", "mox@localhost", "https://localhost/", nil, shutdown) m2, err := Load("test2", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, nil, shutdown)
if err != nil { if err != nil {
t.Fatalf("load another manager: %v", err) t.Fatalf("load another manager: %v", err)
} }

View file

@ -116,15 +116,22 @@ type Dynamic struct {
} }
type ACME struct { type ACME struct {
DirectoryURL string `sconf-doc:"For letsencrypt, use https://acme-v02.api.letsencrypt.org/directory."` DirectoryURL string `sconf-doc:"For letsencrypt, use https://acme-v02.api.letsencrypt.org/directory."`
RenewBefore time.Duration `sconf:"optional" sconf-doc:"How long before expiration to renew the certificate. Default is 30 days."` RenewBefore time.Duration `sconf:"optional" sconf-doc:"How long before expiration to renew the certificate. Default is 30 days."`
ContactEmail string `sconf-doc:"Email address to register at ACME provider. The provider can email you when certificates are about to expire. If you configure an address for which email is delivered by this server, keep in mind that TLS misconfigurations could result in such notification emails not arriving."` ContactEmail string `sconf-doc:"Email address to register at ACME provider. The provider can email you when certificates are about to expire. If you configure an address for which email is delivered by this server, keep in mind that TLS misconfigurations could result in such notification emails not arriving."`
Port int `sconf:"optional" sconf-doc:"TLS port for ACME validation, 443 by default. You should only override this if you cannot listen on port 443 directly. ACME will make requests to port 443, so you'll have to add an external mechanism to get the connection here, e.g. by configuring port forwarding."` Port int `sconf:"optional" sconf-doc:"TLS port for ACME validation, 443 by default. You should only override this if you cannot listen on port 443 directly. ACME will make requests to port 443, so you'll have to add an external mechanism to get the connection here, e.g. by configuring port forwarding."`
IssuerDomainName string `sconf:"optional" sconf-doc:"If set, used for suggested CAA DNS records, for restricting TLS certificate issuance to a Certificate Authority. If empty and DirectyURL is for Let's Encrypt, this value is set automatically to letsencrypt.org."` IssuerDomainName string `sconf:"optional" sconf-doc:"If set, used for suggested CAA DNS records, for restricting TLS certificate issuance to a Certificate Authority. If empty and DirectyURL is for Let's Encrypt, this value is set automatically to letsencrypt.org."`
ExternalAccountBinding *ExternalAccountBinding `sconf:"optional" sconf-doc:"ACME providers can require that a request for a new ACME account reference an existing non-ACME account known to the provider. External account binding references that account by a key id, and authorizes new ACME account requests by signing it with a key known both by the ACME client and ACME provider."`
// ../rfc/8555:2111
Manager *autotls.Manager `sconf:"-" json:"-"` Manager *autotls.Manager `sconf:"-" json:"-"`
} }
type ExternalAccountBinding struct {
KeyID string `sconf-doc:"Key identifier, from ACME provider."`
KeyFile string `sconf-doc:"File containing the base64url-encoded key used to sign account requests with external account binding. The ACME provider will verify the account request is correctly signed by the key. File is evaluated relative to the directory of mox.conf."`
}
type Listener struct { type Listener struct {
IPs []string `sconf-doc:"Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but it is better to explicitly specify the IPs you want to use for email, as mox will make sure outgoing connections will only be made from one of those IPs."` IPs []string `sconf-doc:"Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but it is better to explicitly specify the IPs you want to use for email, as mox will make sure outgoing connections will only be made from one of those IPs."`
NATIPs []string `sconf:"optional" sconf-doc:"If set, the mail server is configured behind a NAT and field IPs are internal instead of the public IPs, while NATIPs lists the public IPs. Used during IP-related DNS self-checks, such as for iprev, mx, spf, autoconfig, autodiscover, and for autotls."` NATIPs []string `sconf:"optional" sconf-doc:"If set, the mail server is configured behind a NAT and field IPs are internal instead of the public IPs, while NATIPs lists the public IPs. Used during IP-related DNS self-checks, such as for iprev, mx, spf, autoconfig, autodiscover, and for autotls."`

View file

@ -101,6 +101,22 @@ describe-static" and "mox config describe-domains":
# Encrypt, this value is set automatically to letsencrypt.org. (optional) # Encrypt, this value is set automatically to letsencrypt.org. (optional)
IssuerDomainName: IssuerDomainName:
# ACME providers can require that a request for a new ACME account reference an
# existing non-ACME account known to the provider. External account binding
# references that account by a key id, and authorizes new ACME account requests by
# signing it with a key known both by the ACME client and ACME provider.
# (optional)
ExternalAccountBinding:
# Key identifier, from ACME provider.
KeyID:
# File containing the base64url-encoded key used to sign account requests with
# external account binding. The ACME provider will verify the account request is
# correctly signed by the key. File is evaluated relative to the directory of
# mox.conf.
KeyFile:
# File containing hash of admin password, for authentication in the web admin # File containing hash of admin password, for authentication in the web admin
# pages (if enabled). (optional) # pages (if enabled). (optional)
AdminPasswordFile: AdminPasswordFile:

View file

@ -11,6 +11,7 @@ import (
"crypto/rsa" "crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
@ -602,12 +603,32 @@ func PrepareStaticConfig(ctx context.Context, log mlog.Log, configFile string, c
} }
} }
for name, acme := range c.ACME { for name, acme := range c.ACME {
var eabKeyID string
var eabKey []byte
if acme.ExternalAccountBinding != nil {
eabKeyID = acme.ExternalAccountBinding.KeyID
p := configDirPath(configFile, acme.ExternalAccountBinding.KeyFile)
buf, err := os.ReadFile(p)
if err != nil {
addErrorf("reading external account binding key for acme provider %q: %s", name, err)
} else {
dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(buf)))
n, err := base64.RawURLEncoding.Decode(dec, buf)
if err != nil {
addErrorf("parsing external account binding key as base64 for acme provider %q: %s", name, err)
} else {
eabKey = dec[:n]
}
}
}
if checkOnly { if checkOnly {
continue continue
} }
acmeDir := dataDirPath(configFile, c.DataDir, "acme") acmeDir := dataDirPath(configFile, c.DataDir, "acme")
os.MkdirAll(acmeDir, 0770) os.MkdirAll(acmeDir, 0770)
manager, err := autotls.Load(name, acmeDir, acme.ContactEmail, acme.DirectoryURL, makeGetPrivateKey(name), Shutdown.Done()) manager, err := autotls.Load(name, acmeDir, acme.ContactEmail, acme.DirectoryURL, eabKeyID, eabKey, makeGetPrivateKey(name), Shutdown.Done())
if err != nil { if err != nil {
addErrorf("loading ACME identity for %q: %s", name, err) addErrorf("loading ACME identity for %q: %s", name, err)
} }