From db3fef4981f25f080803d78b491ca6615b0d9577 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 21 Dec 2023 15:16:30 +0100 Subject: [PATCH] when suggesting CAA records for a domain, suggest variants that bind to the account id and with validation methods used by mox should prevent potential mitm attacks. especially when done close to the machine itself (where a http/tls challenge is intercepted to get a valid certificate), as seen on the internet last month. --- config/config.go | 9 ++-- config/doc.go | 5 ++ main.go | 16 +++++- mox-/admin.go | 73 +++++++++++++++++++++------ mox-/config.go | 6 +++ quickstart.go | 7 +-- rfc/index.txt | 4 ++ testdata/integration/moxacmepebble.sh | 2 +- testdata/integration/moxmail2.sh | 3 +- webadmin/admin.go | 24 ++++++++- webadmin/admin.html | 2 +- 11 files changed, 123 insertions(+), 28 deletions(-) diff --git a/config/config.go b/config/config.go index 8932b58..7024564 100644 --- a/config/config.go +++ b/config/config.go @@ -116,10 +116,11 @@ type Dynamic struct { } type ACME struct { - 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."` - 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."` + 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."` + 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."` + 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."` Manager *autotls.Manager `sconf:"-" json:"-"` } diff --git a/config/doc.go b/config/doc.go index 734265b..5ed532b 100644 --- a/config/doc.go +++ b/config/doc.go @@ -96,6 +96,11 @@ describe-static" and "mox config describe-domains": # configuring port forwarding. (optional) Port: 0 + # 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. (optional) + IssuerDomainName: + # File containing hash of admin password, for authentication in the web admin # pages (if enabled). (optional) AdminPasswordFile: diff --git a/main.go b/main.go index 73e958e..31d1738 100644 --- a/main.go +++ b/main.go @@ -792,7 +792,21 @@ configured. xcheckf(err, "looking up record for dnssec-status") } - records, err := mox.DomainRecords(domConf, d, result.Authentic) + var certIssuerDomainName, acmeAccountURI string + public := mox.Conf.Static.Listeners["public"] + if public.TLS != nil && public.TLS.ACME != "" { + acme, ok := mox.Conf.Static.ACME[public.TLS.ACME] + if ok && acme.Manager.Manager.Client != nil { + certIssuerDomainName = acme.IssuerDomainName + acc, err := acme.Manager.Manager.Client.GetReg(context.Background(), "") + c.log.Check(err, "get public acme account") + if err == nil { + acmeAccountURI = acc.URI + } + } + } + + records, err := mox.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI) xcheckf(err, "records") fmt.Print(strings.Join(records, "\n") + "\n") } diff --git a/mox-/admin.go b/mox-/admin.go index c954f94..6eaa429 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -458,10 +458,16 @@ func WebserverConfigSet(ctx context.Context, domainRedirects map[string]string, // DomainRecords returns text lines describing DNS records required for configuring // a domain. -func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool) ([]string, error) { +// +// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to +// that caID will be suggested. If acmeAccountURI is also set, CAA records also +// restricting issuance to that account ID will be suggested. +func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) { d := domain.ASCII h := Conf.Static.HostnameDomain.ASCII + // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and + // ../testdata/integration/moxmail2.sh for selecting DNS records records := []string{ "; Time To Live of 5 minutes, may be recognized if importing as a zone file.", "; Once your setup is working, you may want to increase the TTL.", @@ -471,15 +477,15 @@ func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool) ([] if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) { records = append(records, - "; DANE: These records indicate that a remote mail server trying to deliver email", - "; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based", - "; on the certificate public key (\"SPKI\", 1) that is SHA2-256-hashed (1) to the", - "; hexadecimal hash. DANE-EE verification means only the certificate or public", - "; key is verified, not whether the certificate is signed by a (centralized)", - "; certificate authority (CA), is expired, or matches the host name.", - ";", - "; NOTE: Create the records below only once: They are for the machine, and apply", - "; to all hosted domains.", + `; DANE: These records indicate that a remote mail server trying to deliver email`, + `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`, + `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`, + `; hexadecimal hash. DANE-EE verification means only the certificate or public`, + `; key is verified, not whether the certificate is signed by a (centralized)`, + `; certificate authority (CA), is expired, or matches the host name.`, + `;`, + `; NOTE: Create the records below only once: They are for the machine, and apply`, + `; to all hosted domains.`, ) if !hasDNSSEC { records = append(records, @@ -666,13 +672,48 @@ func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool) ([] fmt.Sprintf(`_submission._tcp.%s. SRV 0 1 587 .`, d), fmt.Sprintf(`_pop3._tcp.%s. SRV 0 1 110 .`, d), fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 1 995 .`, d), - "", - - "; Optional:", - "; You could mark Let's Encrypt as the only Certificate Authority allowed to", - "; sign TLS certificates for your domain.", - fmt.Sprintf("%s. CAA 0 issue \"letsencrypt.org\"", d), ) + + if certIssuerDomainName != "" { + // ../rfc/8659:18 for CAA records. + records = append(records, + "", + "; Optional:", + "; You could mark Let's Encrypt as the only Certificate Authority allowed to", + "; sign TLS certificates for your domain.", + fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName), + ) + if acmeAccountURI != "" { + // ../rfc/8657:99 for accounturi. + // ../rfc/8657:147 for validationmethods. + records = append(records, + ";", + "; Optionally limit certificates for this domain to the account ID and methods used by mox.", + fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + ";", + "; Or alternatively only limit for email-specific subdomains, so you can use", + "; other accounts/methods for other subdomains.", + fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + fmt.Sprintf(`;; mtasts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + ) + if strings.HasSuffix(h, "."+d) { + records = append(records, + ";", + "; And the mail hostname.", + fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI), + ) + } + } else { + // The string "will be suggested" is used by + // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh + // as end of DNS records. + records = append(records, + ";", + "; Note: After starting up, once an ACME account has been created, CAA records", + "; that restrict issuance to the account will be suggested.", + ) + } + } return records, nil } diff --git a/mox-/config.go b/mox-/config.go index 2ee6dba..a7a0035 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -612,6 +612,12 @@ func PrepareStaticConfig(ctx context.Context, log mlog.Log, configFile string, c addErrorf("loading ACME identity for %q: %s", name, err) } acme.Manager = manager + + // Help configurations from older quickstarts. + if acme.IssuerDomainName == "" && acme.DirectoryURL == "https://acme-v02.api.letsencrypt.org/directory" { + acme.IssuerDomainName = "letsencrypt.org" + } + c.ACME[name] = acme } diff --git a/quickstart.go b/quickstart.go index c916021..1cd7c47 100644 --- a/quickstart.go +++ b/quickstart.go @@ -609,8 +609,9 @@ many authentication failures). if !existingWebserver { sc.ACME = map[string]config.ACME{ "letsencrypt": { - DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory", - ContactEmail: args[0], // todo: let user specify an alternative fallback address? + DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory", + ContactEmail: args[0], // todo: let user specify an alternative fallback address? + IssuerDomainName: "letsencrypt.org", }, } } @@ -893,7 +894,7 @@ configured correctly. // priming dns caches with negative/absent records, causing our "quick setup" to // appear to fail or take longer than "quick". - records, err := mox.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic) + records, err := mox.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "") if err != nil { fatalf("making required DNS records") } diff --git a/rfc/index.txt b/rfc/index.txt index eccc9d4..e9ba2b9 100644 --- a/rfc/index.txt +++ b/rfc/index.txt @@ -319,6 +319,10 @@ See implementation guide, https://jmap.io/server.html 8555 Automatic Certificate Management Environment (ACME) 8737 Automated Certificate Management Environment (ACME) TLS Application-Layer Protocol Negotiation (ALPN) Challenge Extension +# CAA +8657 Certification Authority Authorization (CAA) Record Extensions for Account URI and Automatic Certificate Management Environment (ACME) Method Binding +8659 DNS Certification Authority Authorization (CAA) Resource Record + # DNS 1034 DOMAIN NAMES - CONCEPTS AND FACILITIES 1035 DOMAIN NAMES - IMPLEMENTATION AND SPECIFICATION diff --git a/testdata/integration/moxacmepebble.sh b/testdata/integration/moxacmepebble.sh index 290b514..9c564f0 100755 --- a/testdata/integration/moxacmepebble.sh +++ b/testdata/integration/moxacmepebble.sh @@ -25,7 +25,7 @@ sed -i -e 's/moxtest1@mox1.example: nil/moxtest1@mox1.example: nil\n\t\t\tpostfi ( cat /integration/example.zone; - sed -n '/^;/,/CAA /p' output.txt | + sed -n '/^;/,/will be suggested/p' output.txt | # allow sending from postfix for mox1.example. sed 's/mox1.example. *TXT "v=spf1 mx ~all"/mox1.example. TXT "v=spf1 mx ip4:172.28.1.70 ~all"/' ) >/integration/example-integration.zone diff --git a/testdata/integration/moxmail2.sh b/testdata/integration/moxmail2.sh index c4b2cfe..f8669e1 100755 --- a/testdata/integration/moxmail2.sh +++ b/testdata/integration/moxmail2.sh @@ -23,7 +23,8 @@ TLS: EOF # A fresh file was set up by moxacmepebble. -sed -n '/^;/,/CAA /p' output.txt >>/integration/example-integration.zone +sed -n '/^;/,/will be suggested/p' output.txt >>/integration/example-integration.zone + unbound-control -s 172.28.1.30 reload # reload unbound with zone file changes mox -checkconsistency serve & diff --git a/webadmin/admin.go b/webadmin/admin.go index f1fda86..f6ad046 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -1774,6 +1774,13 @@ func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[ // DomainRecords returns lines describing DNS records that should exist for the // configured domain. func (Admin) DomainRecords(ctx context.Context, domain string) []string { + log := pkglog.WithContext(ctx) + return DomainRecords(ctx, log, domain) +} + +// DomainRecords is the implementation of API function Admin.DomainRecords, taking +// a logger. +func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string { d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") dc, ok := mox.Conf.Domain(d) @@ -1785,7 +1792,22 @@ func (Admin) DomainRecords(ctx context.Context, domain string) []string { if !dns.IsNotFound(err) { xcheckf(ctx, err, "looking up record to determine if dnssec is implemented") } - records, err := mox.DomainRecords(dc, d, result.Authentic) + + var certIssuerDomainName, acmeAccountURI string + public := mox.Conf.Static.Listeners["public"] + if public.TLS != nil && public.TLS.ACME != "" { + acme, ok := mox.Conf.Static.ACME[public.TLS.ACME] + if ok && acme.Manager.Manager.Client != nil { + certIssuerDomainName = acme.IssuerDomainName + acc, err := acme.Manager.Manager.Client.GetReg(ctx, "") + log.Check(err, "get public acme account") + if err == nil { + acmeAccountURI = acc.URI + } + } + } + + records, err := mox.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI) xcheckf(ctx, err, "dns records") return records } diff --git a/webadmin/admin.html b/webadmin/admin.html index 5df0117..bc036ab 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -914,7 +914,7 @@ const domainDNSRecords = async (d) => { 'DNS Records', ), dom.h1('Required DNS records'), - dom('pre.literal', style({maxWidth: '70em'}), records.join('\n')), + dom('pre.literal', records.join('\n')), dom.br(), ) }