diff --git a/mox-/admin.go b/admin/admin.go
similarity index 57%
rename from mox-/admin.go
rename to admin/admin.go
index ac4dc7f555..96746c4630 100644
--- a/mox-/admin.go
+++ b/admin/admin.go
@@ -1,67 +1,37 @@
-package mox
+package admin
import (
"bytes"
"context"
- "crypto"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/rsa"
- "crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log/slog"
- "net"
- "net/url"
"os"
"path/filepath"
"slices"
- "sort"
"strings"
"time"
"golang.org/x/exp/maps"
- "github.com/mjl-/adns"
-
"github.com/mjl-/mox/config"
- "github.com/mjl-/mox/dkim"
- "github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
+ "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp"
- "github.com/mjl-/mox/spf"
- "github.com/mjl-/mox/tlsrpt"
+ "github.com/mjl-/mox/store"
)
-var ErrRequest = errors.New("bad request")
+var pkglog = mlog.New("admin", nil)
-// TXTStrings returns a TXT record value as one or more quoted strings, each max
-// 100 characters. In case of multiple strings, a multi-line record is returned.
-func TXTStrings(s string) string {
- if len(s) <= 100 {
- return `"` + s + `"`
- }
-
- r := "(\n"
- for len(s) > 0 {
- n := len(s)
- if n > 100 {
- n = 100
- }
- if r != "" {
- r += " "
- }
- r += "\t\t\"" + s[:n] + "\"\n"
- s = s[n:]
- }
- r += "\t)"
- return r
-}
+var ErrRequest = errors.New("bad request")
// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
// with DKIM.
@@ -206,7 +176,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
addSelector := func(kind, name string, privKey []byte) error {
record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
- p := configDirPath(ConfigDynamicPath, keyPath)
+ p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil {
return err
}
@@ -323,10 +293,9 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
}
// Only take lock now, we don't want to hold it while generating a key.
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
@@ -339,7 +308,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
timestamp := time.Now().Format("20060102T150405")
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
- p := configDirPath(ConfigDynamicPath, keyPath)
+ p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil {
return fmt.Errorf("writing key file: %v", err)
}
@@ -377,7 +346,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
}
nc.Domains[domain.Name()] = nd
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@@ -397,10 +366,9 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
@@ -433,7 +401,7 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
}
nc.Domains[domain.Name()] = nd
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@@ -463,10 +431,9 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
if _, ok := c.Domains[domain.Name()]; ok {
return fmt.Errorf("%w: domain already present", ErrRequest)
}
@@ -481,14 +448,14 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
// Only enable mta-sts for domain if there is a listener with mta-sts.
var withMTASTS bool
- for _, l := range Conf.Static.Listeners {
+ for _, l := range mox.Conf.Static.Listeners {
if l.MTASTSHTTPS.Enabled {
withMTASTS = true
break
}
}
- confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
+ confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS)
if err != nil {
return fmt.Errorf("preparing domain config: %v", err)
}
@@ -507,7 +474,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
return fmt.Errorf("%w: account name is empty", ErrRequest)
} else if !ok {
nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain))
- } else if accountName != Conf.Static.Postmaster.Account {
+ } else if accountName != mox.Conf.Static.Postmaster.Account {
nacc := nc.Accounts[accountName]
nd := map[string]config.Destination{}
for k, v := range nacc.Destinations {
@@ -521,7 +488,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
nc.Domains[domain.Name()] = confDomain
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("domain added", slog.Any("domain", domain))
@@ -540,15 +507,26 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
domConf, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
}
+ // Check that the domain isn't referenced in a TLS public key.
+ tlspubkeys, err := store.TLSPublicKeyList(ctx, "")
+ if err != nil {
+ return fmt.Errorf("%w: listing tls public keys: %s", ErrRequest, err)
+ }
+ atdom := "@" + domain.Name()
+ for _, tpk := range tlspubkeys {
+ if strings.HasSuffix(tpk.LoginAddress, atdom) {
+ return fmt.Errorf("%w: domain is still referenced in tls public key by login address %q of account %q, change or remove it first", ErrRequest, tpk.LoginAddress, tpk.Account)
+ }
+ }
+
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
@@ -560,7 +538,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
}
}
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@@ -588,8 +566,8 @@ func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths ma
if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
continue
}
- src := ConfigDirPath(sel.PrivateKeyFile)
- dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
+ src := mox.ConfigDirPath(sel.PrivateKeyFile)
+ dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
_, err := os.Stat(dst)
if err == nil {
err = fmt.Errorf("destination already exists")
@@ -615,10 +593,9 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- nc := Conf.Dynamic // Shallow copy.
+ nc := mox.Conf.Dynamic // Shallow copy.
dom, ok := nc.Domains[domainName] // dom is a shallow copy.
if !ok {
return fmt.Errorf("%w: domain not present", ErrRequest)
@@ -631,12 +608,12 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc.Domains = map[string]config.Domain{}
- for name, d := range Conf.Dynamic.Domains {
+ for name, d := range mox.Conf.Dynamic.Domains {
nc.Domains[name] = d
}
nc.Domains[domainName] = dom
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@@ -656,13 +633,12 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- nc := Conf.Dynamic // Shallow copy.
+ nc := mox.Conf.Dynamic // Shallow copy.
xmodify(&nc)
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@@ -670,330 +646,6 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
return nil
}
-// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
-// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
-// transports.
-func DomainSPFIPs() (ips []net.IP) {
- for _, l := range Conf.Static.Listeners {
- if !l.SMTP.Enabled || l.IPsNATed {
- continue
- }
- ipstrs := l.IPs
- if len(l.NATIPs) > 0 {
- ipstrs = l.NATIPs
- }
- for _, ipstr := range ipstrs {
- ip := net.ParseIP(ipstr)
- if ip.IsUnspecified() {
- continue
- }
- ips = append(ips, ip)
- }
- }
- for _, t := range Conf.Static.Transports {
- if t.Socks != nil {
- ips = append(ips, t.Socks.IPs...)
- }
- }
- return ips
-}
-
-// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
-
-// DomainRecords returns text lines describing DNS records required for configuring
-// a domain.
-//
-// 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.",
- "$TTL 300",
- "",
- }
-
- 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.`,
- )
- if !hasDNSSEC {
- records = append(records,
- ";",
- "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
- "; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
- "; commented out.",
- )
- }
- addTLSA := func(privKey crypto.Signer) error {
- spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
- if err != nil {
- return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
- }
- sum := sha256.Sum256(spkiBuf)
- tlsaRecord := adns.TLSA{
- Usage: adns.TLSAUsageDANEEE,
- Selector: adns.TLSASelectorSPKI,
- MatchType: adns.TLSAMatchTypeSHA256,
- CertAssoc: sum[:],
- }
- var s string
- if hasDNSSEC {
- s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
- } else {
- s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
- }
- records = append(records, s)
- return nil
- }
- for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
- if err := addTLSA(privKey); err != nil {
- return nil, err
- }
- }
- for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
- if err := addTLSA(privKey); err != nil {
- return nil, err
- }
- }
- records = append(records, "")
- }
-
- if d != h {
- records = append(records,
- "; For the machine, only needs to be created once, for the first domain added:",
- "; ",
- "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
- "; messages (DSNs) sent from host:",
- fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
- "",
- )
- }
- if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
- uri := url.URL{
- Scheme: "mailto",
- Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false),
- }
- tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
- records = append(records,
- "; For the machine, only needs to be created once, for the first domain added:",
- "; ",
- "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
- fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
- "",
- )
- }
-
- records = append(records,
- "; Deliver email for the domain to this host.",
- fmt.Sprintf("%s. MX 10 %s.", d, h),
- "",
-
- "; Outgoing messages will be signed with the first two DKIM keys. The other two",
- "; configured for backup, switching to them is just a config change.",
- )
- var selectors []string
- for name := range domConf.DKIM.Selectors {
- selectors = append(selectors, name)
- }
- sort.Slice(selectors, func(i, j int) bool {
- return selectors[i] < selectors[j]
- })
- for _, name := range selectors {
- sel := domConf.DKIM.Selectors[name]
- dkimr := dkim.Record{
- Version: "DKIM1",
- Hashes: []string{"sha256"},
- PublicKey: sel.Key.Public(),
- }
- if _, ok := sel.Key.(ed25519.PrivateKey); ok {
- dkimr.Key = "ed25519"
- } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
- return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
- }
- txt, err := dkimr.Record()
- if err != nil {
- return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
- }
-
- if len(txt) > 100 {
- records = append(records,
- "; NOTE: The following is a single long record split over several lines for use",
- "; in zone files. When adding through a DNS operator web interface, combine the",
- "; strings into a single string, without ().",
- )
- }
- s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt))
- records = append(records, s)
-
- }
- dmarcr := dmarc.DefaultRecord
- dmarcr.Policy = "reject"
- if domConf.DMARC != nil {
- uri := url.URL{
- Scheme: "mailto",
- Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
- }
- dmarcr.AggregateReportAddresses = []dmarc.URI{
- {Address: uri.String(), MaxSize: 10, Unit: "m"},
- }
- }
- dspfr := spf.Record{Version: "spf1"}
- for _, ip := range DomainSPFIPs() {
- mech := "ip4"
- if ip.To4() == nil {
- mech = "ip6"
- }
- dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
- }
- dspfr.Directives = append(dspfr.Directives,
- spf.Directive{Mechanism: "mx"},
- spf.Directive{Qualifier: "~", Mechanism: "all"},
- )
- dspftxt, err := dspfr.Record()
- if err != nil {
- return nil, fmt.Errorf("making domain spf record: %v", err)
- }
- records = append(records,
- "",
-
- "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
- "; ~all means softfail for anything else, which is done instead of -all to prevent older",
- "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
- fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
- "",
-
- "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
- "; should be rejected, and request reports. If you email through mailing lists that",
- "; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
- "; set the policy to p=none.",
- fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
- "",
- )
-
- if sts := domConf.MTASTS; sts != nil {
- records = append(records,
- "; Remote servers can use MTA-STS to verify our TLS certificate with the",
- "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
- "; STARTTLSTLS.",
- fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
- fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
- "",
- )
- } else {
- records = append(records,
- "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
- "; domain or because mox.conf does not have a listener with MTA-STS configured.",
- "",
- )
- }
-
- if domConf.TLSRPT != nil {
- uri := url.URL{
- Scheme: "mailto",
- Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
- }
- tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
- records = append(records,
- "; Request reporting about TLS failures.",
- fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
- "",
- )
- }
-
- if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
- records = append(records,
- "; Client settings will reference a subdomain of the hosted domain, making it",
- "; easier to migrate to a different server in the future by not requiring settings",
- "; in all clients to be updated.",
- fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
- "",
- )
- }
-
- records = append(records,
- "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
- fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
- fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
- "",
-
- // ../rfc/6186:133 ../rfc/8314:692
- "; For secure IMAP and submission autoconfig, point to mail host.",
- fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
- fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
- "",
- // ../rfc/6186:242
- "; Next records specify POP3 and non-TLS ports are not to be used.",
- "; These are optional and safe to leave out (e.g. if you have to click a lot in a",
- "; DNS admin web interface).",
- fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
- fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
- fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
- fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, 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(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
- )
- if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
- records = append(records,
- fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, 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
-}
-
// AccountAdd adds an account and an initial address and reloads the configuration.
//
// The new account does not have a password, so cannot yet log in. Email can be
@@ -1013,10 +665,9 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
}
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; ok {
return fmt.Errorf("%w: account already present", ErrRequest)
}
@@ -1034,7 +685,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
}
nc.Accounts[account] = MakeAccountConfig(addr)
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("account added", slog.String("account", account), slog.Any("address", addr))
@@ -1050,10 +701,9 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest)
}
@@ -1068,12 +718,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
}
}
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
- odir := filepath.Join(DataDirPath("accounts"), account)
- tmpdir := filepath.Join(DataDirPath("tmp"), "oldaccount-"+account)
+ odir := filepath.Join(mox.DataDirPath("accounts"), account)
+ tmpdir := filepath.Join(mox.DataDirPath("tmp"), "oldaccount-"+account)
if err := os.Rename(odir, tmpdir); err != nil {
log.Errorx("moving old account data directory out of the way", err, slog.String("account", account))
return fmt.Errorf("account removed, but account data directory %q could not be moved out of the way: %v", odir, err)
@@ -1083,6 +733,11 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
return fmt.Errorf("account removed, its data directory moved to %q, but removing failed: %v", odir, err)
}
+ if err := store.TLSPublicKeyRemoveForAccount(context.Background(), account); err != nil {
+ log.Errorx("removing tls public keys for removed account", err)
+ return fmt.Errorf("account removed, but removing tls public keys failed: %v", err)
+ }
+
log.Info("account removed", slog.String("account", account))
return nil
}
@@ -1093,12 +748,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
//
// Must be called with config lock held.
func checkAddressAvailable(addr smtp.Address) error {
- dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]
+ dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()]
if !ok {
return fmt.Errorf("domain does not exist")
}
- lp := CanonicalLocalpart(addr.Localpart, dc)
- if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
+ lp := mox.CanonicalLocalpart(addr.Localpart, dc)
+ if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok {
return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
@@ -1118,10 +773,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
a, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest)
@@ -1135,9 +789,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
}
dname := d.Name()
destAddr = "@" + dname
- if _, ok := Conf.Dynamic.Domains[dname]; !ok {
+ if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
- } else if _, ok := Conf.accountDestinations[destAddr]; ok {
+ } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok {
return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
}
} else {
@@ -1167,7 +821,7 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
a.Destinations = nd
nc.Accounts[account] = a
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("address added", slog.String("address", address), slog.String("account", account))
@@ -1187,17 +841,16 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- ad, ok := Conf.accountDestinations[address]
+ ad, ok := mox.Conf.AccountDestinationsLocked[address]
if !ok {
return fmt.Errorf("%w: address does not exists", ErrRequest)
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
- a, ok := Conf.Dynamic.Accounts[ad.Account]
+ a, ok := mox.Conf.Dynamic.Accounts[ad.Account]
if !ok {
return fmt.Errorf("internal error: cannot find account")
}
@@ -1216,7 +869,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
// Also remove matching address from FromIDLoginAddresses, composing a new slice.
- var fromIDLoginAddresses []string
+ // Refuse if address is referenced in a TLS public key.
var dom dns.Domain
var pa smtp.Address // For non-catchall addresses (most).
var err error
@@ -1232,6 +885,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
dom = pa.Domain
}
+ dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
+ if !ok {
+ return fmt.Errorf("%w: unknown domain in address %q", ErrRequest, address)
+ }
+
+ var fromIDLoginAddresses []string
for i, fa := range a.ParsedFromIDLoginAddresses {
if fa.Domain != dom {
// Keep for different domain.
@@ -1241,12 +900,8 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if strings.HasPrefix(address, "@") {
continue
}
- dc, ok := Conf.Dynamic.Domains[dom.Name()]
- if !ok {
- return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
- }
- flp := CanonicalLocalpart(fa.Localpart, dc)
- alp := CanonicalLocalpart(pa.Localpart, dc)
+ flp := mox.CanonicalLocalpart(fa.Localpart, dc)
+ alp := mox.CanonicalLocalpart(pa.Localpart, dc)
if alp != flp {
// Keep for different localpart.
fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
@@ -1254,8 +909,25 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
na.FromIDLoginAddresses = fromIDLoginAddresses
+ // Refuse if there is still a TLS public key that references this address.
+ tlspubkeys, err := store.TLSPublicKeyList(ctx, ad.Account)
+ if err != nil {
+ return fmt.Errorf("%w: listing tls public keys for account: %v", ErrRequest, err)
+ }
+ for _, tpk := range tlspubkeys {
+ a, err := smtp.ParseAddress(tpk.LoginAddress)
+ if err != nil {
+ return fmt.Errorf("%w: parsing address from tls public key: %v", ErrRequest, err)
+ }
+ lp := mox.CanonicalLocalpart(a.Localpart, dc)
+ ca := smtp.NewAddress(lp, a.Domain)
+ if xad, ok := mox.Conf.AccountDestinationsLocked[ca.String()]; ok && xad.Localpart == ad.Localpart {
+ return fmt.Errorf("%w: tls public key %q references this address as login address %q, remove the tls public key before removing the address", ErrRequest, tpk.Fingerprint, tpk.LoginAddress)
+ }
+ }
+
// And remove as member from aliases configured in domains.
- domains := maps.Clone(Conf.Dynamic.Domains)
+ domains := maps.Clone(mox.Conf.Dynamic.Domains)
for _, aa := range na.Aliases {
if aa.SubscriptionAddress != address {
continue
@@ -1263,7 +935,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
- dom, ok := Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
+ dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
if !ok {
return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
}
@@ -1283,15 +955,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
na.Aliases = nil // Filled when parsing config.
- nc := Conf.Dynamic
+ nc := mox.Conf.Dynamic
nc.Accounts = map[string]config.Account{}
- for name, a := range Conf.Dynamic.Accounts {
+ for name, a := range mox.Conf.Dynamic.Accounts {
nc.Accounts[name] = a
}
nc.Accounts[ad.Account] = na
nc.Domains = domains
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
@@ -1393,10 +1065,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
}
}()
- Conf.dynamicMutex.Lock()
- defer Conf.dynamicMutex.Unlock()
+ defer mox.Conf.DynamicLockUnlock()()
- c := Conf.Dynamic
+ c := mox.Conf.Dynamic
acc, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("%w: account not present", ErrRequest)
@@ -1413,243 +1084,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
}
nc.Accounts[account] = acc
- if err := writeDynamic(ctx, log, nc); err != nil {
+ if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("account fields saved", slog.String("account", account))
return nil
}
-
-type TLSMode uint8
-
-const (
- TLSModeImmediate TLSMode = 0
- TLSModeSTARTTLS TLSMode = 1
- TLSModeNone TLSMode = 2
-)
-
-type ProtocolConfig struct {
- Host dns.Domain
- Port int
- TLSMode TLSMode
-}
-
-type ClientConfig struct {
- IMAP ProtocolConfig
- Submission ProtocolConfig
-}
-
-// ClientConfigDomain returns a single IMAP and Submission client configuration for
-// a domain.
-func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
- var haveIMAP, haveSubmission bool
-
- domConf, ok := Conf.Domain(d)
- if !ok {
- return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
- }
-
- gather := func(l config.Listener) (done bool) {
- host := Conf.Static.HostnameDomain
- if l.Hostname != "" {
- host = l.HostnameDomain
- }
- if domConf.ClientSettingsDomain != "" {
- host = domConf.ClientSettingsDNSDomain
- }
- if !haveIMAP && l.IMAPS.Enabled {
- rconfig.IMAP.Host = host
- rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
- rconfig.IMAP.TLSMode = TLSModeImmediate
- haveIMAP = true
- }
- if !haveIMAP && l.IMAP.Enabled {
- rconfig.IMAP.Host = host
- rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
- rconfig.IMAP.TLSMode = TLSModeSTARTTLS
- if l.TLS == nil {
- rconfig.IMAP.TLSMode = TLSModeNone
- }
- haveIMAP = true
- }
- if !haveSubmission && l.Submissions.Enabled {
- rconfig.Submission.Host = host
- rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
- rconfig.Submission.TLSMode = TLSModeImmediate
- haveSubmission = true
- }
- if !haveSubmission && l.Submission.Enabled {
- rconfig.Submission.Host = host
- rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
- rconfig.Submission.TLSMode = TLSModeSTARTTLS
- if l.TLS == nil {
- rconfig.Submission.TLSMode = TLSModeNone
- }
- haveSubmission = true
- }
- return haveIMAP && haveSubmission
- }
-
- // Look at the public listener first. Most likely the intended configuration.
- if public, ok := Conf.Static.Listeners["public"]; ok {
- if gather(public) {
- return
- }
- }
- // Go through the other listeners in consistent order.
- names := maps.Keys(Conf.Static.Listeners)
- sort.Strings(names)
- for _, name := range names {
- if gather(Conf.Static.Listeners[name]) {
- return
- }
- }
- return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
-}
-
-// ClientConfigs holds the client configuration for IMAP/Submission for a
-// domain.
-type ClientConfigs struct {
- Entries []ClientConfigsEntry
-}
-
-type ClientConfigsEntry struct {
- Protocol string
- Host dns.Domain
- Port int
- Listener string
- Note string
-}
-
-// ClientConfigsDomain returns the client configs for IMAP/Submission for a
-// domain.
-func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
- domConf, ok := Conf.Domain(d)
- if !ok {
- return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
- }
-
- c := ClientConfigs{}
- c.Entries = []ClientConfigsEntry{}
- var listeners []string
-
- for name := range Conf.Static.Listeners {
- listeners = append(listeners, name)
- }
- sort.Slice(listeners, func(i, j int) bool {
- return listeners[i] < listeners[j]
- })
-
- note := func(tls bool, requiretls bool) string {
- if !tls {
- return "plain text, no STARTTLS configured"
- }
- if requiretls {
- return "STARTTLS required"
- }
- return "STARTTLS optional"
- }
-
- for _, name := range listeners {
- l := Conf.Static.Listeners[name]
- host := Conf.Static.HostnameDomain
- if l.Hostname != "" {
- host = l.HostnameDomain
- }
- if domConf.ClientSettingsDomain != "" {
- host = domConf.ClientSettingsDNSDomain
- }
- if l.Submissions.Enabled {
- c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
- }
- if l.IMAPS.Enabled {
- c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
- }
- if l.Submission.Enabled {
- c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
- }
- if l.IMAP.Enabled {
- c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
- }
- }
-
- return c, nil
-}
-
-// IPs returns ip addresses we may be listening/receiving mail on or
-// connecting/sending from to the outside.
-func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
- log := pkglog.WithContext(ctx)
-
- // Try to gather all IPs we are listening on by going through the config.
- // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
- var ips []net.IP
- var ipv4all, ipv6all bool
- for _, l := range Conf.Static.Listeners {
- // If NATed, we don't know our external IPs.
- if l.IPsNATed {
- return nil, nil
- }
- check := l.IPs
- if len(l.NATIPs) > 0 {
- check = l.NATIPs
- }
- for _, s := range check {
- ip := net.ParseIP(s)
- if ip.IsUnspecified() {
- if ip.To4() != nil {
- ipv4all = true
- } else {
- ipv6all = true
- }
- continue
- }
- ips = append(ips, ip)
- }
- }
-
- // We'll list the IPs on the interfaces. How useful is this? There is a good chance
- // we're listening on all addresses because of a load balancer/firewall.
- if ipv4all || ipv6all {
- ifaces, err := net.Interfaces()
- if err != nil {
- return nil, fmt.Errorf("listing network interfaces: %v", err)
- }
- for _, iface := range ifaces {
- if iface.Flags&net.FlagUp == 0 {
- continue
- }
- addrs, err := iface.Addrs()
- if err != nil {
- return nil, fmt.Errorf("listing addresses for network interface: %v", err)
- }
- if len(addrs) == 0 {
- continue
- }
-
- for _, addr := range addrs {
- ip, _, err := net.ParseCIDR(addr.String())
- if err != nil {
- log.Errorx("bad interface addr", err, slog.Any("address", addr))
- continue
- }
- v4 := ip.To4() != nil
- if ipv4all && v4 || ipv6all && !v4 {
- ips = append(ips, ip)
- }
- }
- }
- }
-
- if receiveOnly {
- return ips, nil
- }
-
- for _, t := range Conf.Static.Transports {
- if t.Socks != nil {
- ips = append(ips, t.Socks.IPs...)
- }
- }
-
- return ips, nil
-}
diff --git a/admin/clientconfig.go b/admin/clientconfig.go
new file mode 100644
index 0000000000..df789674e9
--- /dev/null
+++ b/admin/clientconfig.go
@@ -0,0 +1,168 @@
+package admin
+
+import (
+ "fmt"
+ "sort"
+
+ "golang.org/x/exp/maps"
+
+ "github.com/mjl-/mox/config"
+ "github.com/mjl-/mox/dns"
+ "github.com/mjl-/mox/mox-"
+)
+
+type TLSMode uint8
+
+const (
+ TLSModeImmediate TLSMode = 0
+ TLSModeSTARTTLS TLSMode = 1
+ TLSModeNone TLSMode = 2
+)
+
+type ProtocolConfig struct {
+ Host dns.Domain
+ Port int
+ TLSMode TLSMode
+}
+
+type ClientConfig struct {
+ IMAP ProtocolConfig
+ Submission ProtocolConfig
+}
+
+// ClientConfigDomain returns a single IMAP and Submission client configuration for
+// a domain.
+func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
+ var haveIMAP, haveSubmission bool
+
+ domConf, ok := mox.Conf.Domain(d)
+ if !ok {
+ return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
+ }
+
+ gather := func(l config.Listener) (done bool) {
+ host := mox.Conf.Static.HostnameDomain
+ if l.Hostname != "" {
+ host = l.HostnameDomain
+ }
+ if domConf.ClientSettingsDomain != "" {
+ host = domConf.ClientSettingsDNSDomain
+ }
+ if !haveIMAP && l.IMAPS.Enabled {
+ rconfig.IMAP.Host = host
+ rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
+ rconfig.IMAP.TLSMode = TLSModeImmediate
+ haveIMAP = true
+ }
+ if !haveIMAP && l.IMAP.Enabled {
+ rconfig.IMAP.Host = host
+ rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
+ rconfig.IMAP.TLSMode = TLSModeSTARTTLS
+ if l.TLS == nil {
+ rconfig.IMAP.TLSMode = TLSModeNone
+ }
+ haveIMAP = true
+ }
+ if !haveSubmission && l.Submissions.Enabled {
+ rconfig.Submission.Host = host
+ rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
+ rconfig.Submission.TLSMode = TLSModeImmediate
+ haveSubmission = true
+ }
+ if !haveSubmission && l.Submission.Enabled {
+ rconfig.Submission.Host = host
+ rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
+ rconfig.Submission.TLSMode = TLSModeSTARTTLS
+ if l.TLS == nil {
+ rconfig.Submission.TLSMode = TLSModeNone
+ }
+ haveSubmission = true
+ }
+ return haveIMAP && haveSubmission
+ }
+
+ // Look at the public listener first. Most likely the intended configuration.
+ if public, ok := mox.Conf.Static.Listeners["public"]; ok {
+ if gather(public) {
+ return
+ }
+ }
+ // Go through the other listeners in consistent order.
+ names := maps.Keys(mox.Conf.Static.Listeners)
+ sort.Strings(names)
+ for _, name := range names {
+ if gather(mox.Conf.Static.Listeners[name]) {
+ return
+ }
+ }
+ return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
+}
+
+// ClientConfigs holds the client configuration for IMAP/Submission for a
+// domain.
+type ClientConfigs struct {
+ Entries []ClientConfigsEntry
+}
+
+type ClientConfigsEntry struct {
+ Protocol string
+ Host dns.Domain
+ Port int
+ Listener string
+ Note string
+}
+
+// ClientConfigsDomain returns the client configs for IMAP/Submission for a
+// domain.
+func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
+ domConf, ok := mox.Conf.Domain(d)
+ if !ok {
+ return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
+ }
+
+ c := ClientConfigs{}
+ c.Entries = []ClientConfigsEntry{}
+ var listeners []string
+
+ for name := range mox.Conf.Static.Listeners {
+ listeners = append(listeners, name)
+ }
+ sort.Slice(listeners, func(i, j int) bool {
+ return listeners[i] < listeners[j]
+ })
+
+ note := func(tls bool, requiretls bool) string {
+ if !tls {
+ return "plain text, no STARTTLS configured"
+ }
+ if requiretls {
+ return "STARTTLS required"
+ }
+ return "STARTTLS optional"
+ }
+
+ for _, name := range listeners {
+ l := mox.Conf.Static.Listeners[name]
+ host := mox.Conf.Static.HostnameDomain
+ if l.Hostname != "" {
+ host = l.HostnameDomain
+ }
+ if domConf.ClientSettingsDomain != "" {
+ host = domConf.ClientSettingsDNSDomain
+ }
+ if l.Submissions.Enabled {
+ c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
+ }
+ if l.IMAPS.Enabled {
+ c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
+ }
+ if l.Submission.Enabled {
+ c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
+ }
+ if l.IMAP.Enabled {
+ c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
+ }
+ }
+
+ return c, nil
+}
diff --git a/admin/dnsrecords.go b/admin/dnsrecords.go
new file mode 100644
index 0000000000..5c92b59bb6
--- /dev/null
+++ b/admin/dnsrecords.go
@@ -0,0 +1,320 @@
+package admin
+
+import (
+ "crypto"
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/mjl-/adns"
+
+ "github.com/mjl-/mox/config"
+ "github.com/mjl-/mox/dkim"
+ "github.com/mjl-/mox/dmarc"
+ "github.com/mjl-/mox/dns"
+ "github.com/mjl-/mox/mox-"
+ "github.com/mjl-/mox/smtp"
+ "github.com/mjl-/mox/spf"
+ "github.com/mjl-/mox/tlsrpt"
+)
+
+// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
+
+// DomainRecords returns text lines describing DNS records required for configuring
+// a domain.
+//
+// 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 := mox.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.",
+ "$TTL 300",
+ "",
+ }
+
+ if public, ok := mox.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.`,
+ )
+ if !hasDNSSEC {
+ records = append(records,
+ ";",
+ "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
+ "; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
+ "; commented out.",
+ )
+ }
+ addTLSA := func(privKey crypto.Signer) error {
+ spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
+ if err != nil {
+ return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
+ }
+ sum := sha256.Sum256(spkiBuf)
+ tlsaRecord := adns.TLSA{
+ Usage: adns.TLSAUsageDANEEE,
+ Selector: adns.TLSASelectorSPKI,
+ MatchType: adns.TLSAMatchTypeSHA256,
+ CertAssoc: sum[:],
+ }
+ var s string
+ if hasDNSSEC {
+ s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
+ } else {
+ s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
+ }
+ records = append(records, s)
+ return nil
+ }
+ for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
+ if err := addTLSA(privKey); err != nil {
+ return nil, err
+ }
+ }
+ for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
+ if err := addTLSA(privKey); err != nil {
+ return nil, err
+ }
+ }
+ records = append(records, "")
+ }
+
+ if d != h {
+ records = append(records,
+ "; For the machine, only needs to be created once, for the first domain added:",
+ "; ",
+ "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
+ "; messages (DSNs) sent from host:",
+ fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
+ "",
+ )
+ }
+ if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
+ uri := url.URL{
+ Scheme: "mailto",
+ Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false),
+ }
+ tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
+ records = append(records,
+ "; For the machine, only needs to be created once, for the first domain added:",
+ "; ",
+ "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
+ fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
+ "",
+ )
+ }
+
+ records = append(records,
+ "; Deliver email for the domain to this host.",
+ fmt.Sprintf("%s. MX 10 %s.", d, h),
+ "",
+
+ "; Outgoing messages will be signed with the first two DKIM keys. The other two",
+ "; configured for backup, switching to them is just a config change.",
+ )
+ var selectors []string
+ for name := range domConf.DKIM.Selectors {
+ selectors = append(selectors, name)
+ }
+ sort.Slice(selectors, func(i, j int) bool {
+ return selectors[i] < selectors[j]
+ })
+ for _, name := range selectors {
+ sel := domConf.DKIM.Selectors[name]
+ dkimr := dkim.Record{
+ Version: "DKIM1",
+ Hashes: []string{"sha256"},
+ PublicKey: sel.Key.Public(),
+ }
+ if _, ok := sel.Key.(ed25519.PrivateKey); ok {
+ dkimr.Key = "ed25519"
+ } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
+ return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
+ }
+ txt, err := dkimr.Record()
+ if err != nil {
+ return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
+ }
+
+ if len(txt) > 100 {
+ records = append(records,
+ "; NOTE: The following is a single long record split over several lines for use",
+ "; in zone files. When adding through a DNS operator web interface, combine the",
+ "; strings into a single string, without ().",
+ )
+ }
+ s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt))
+ records = append(records, s)
+
+ }
+ dmarcr := dmarc.DefaultRecord
+ dmarcr.Policy = "reject"
+ if domConf.DMARC != nil {
+ uri := url.URL{
+ Scheme: "mailto",
+ Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
+ }
+ dmarcr.AggregateReportAddresses = []dmarc.URI{
+ {Address: uri.String(), MaxSize: 10, Unit: "m"},
+ }
+ }
+ dspfr := spf.Record{Version: "spf1"}
+ for _, ip := range mox.DomainSPFIPs() {
+ mech := "ip4"
+ if ip.To4() == nil {
+ mech = "ip6"
+ }
+ dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
+ }
+ dspfr.Directives = append(dspfr.Directives,
+ spf.Directive{Mechanism: "mx"},
+ spf.Directive{Qualifier: "~", Mechanism: "all"},
+ )
+ dspftxt, err := dspfr.Record()
+ if err != nil {
+ return nil, fmt.Errorf("making domain spf record: %v", err)
+ }
+ records = append(records,
+ "",
+
+ "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
+ "; ~all means softfail for anything else, which is done instead of -all to prevent older",
+ "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
+ fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
+ "",
+
+ "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
+ "; should be rejected, and request reports. If you email through mailing lists that",
+ "; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
+ "; set the policy to p=none.",
+ fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
+ "",
+ )
+
+ if sts := domConf.MTASTS; sts != nil {
+ records = append(records,
+ "; Remote servers can use MTA-STS to verify our TLS certificate with the",
+ "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
+ "; STARTTLSTLS.",
+ fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
+ fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
+ "",
+ )
+ } else {
+ records = append(records,
+ "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
+ "; domain or because mox.conf does not have a listener with MTA-STS configured.",
+ "",
+ )
+ }
+
+ if domConf.TLSRPT != nil {
+ uri := url.URL{
+ Scheme: "mailto",
+ Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
+ }
+ tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
+ records = append(records,
+ "; Request reporting about TLS failures.",
+ fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
+ "",
+ )
+ }
+
+ if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
+ records = append(records,
+ "; Client settings will reference a subdomain of the hosted domain, making it",
+ "; easier to migrate to a different server in the future by not requiring settings",
+ "; in all clients to be updated.",
+ fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
+ "",
+ )
+ }
+
+ records = append(records,
+ "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
+ fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
+ fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
+ "",
+
+ // ../rfc/6186:133 ../rfc/8314:692
+ "; For secure IMAP and submission autoconfig, point to mail host.",
+ fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
+ fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
+ "",
+ // ../rfc/6186:242
+ "; Next records specify POP3 and non-TLS ports are not to be used.",
+ "; These are optional and safe to leave out (e.g. if you have to click a lot in a",
+ "; DNS admin web interface).",
+ fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
+ fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
+ fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
+ fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, 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(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
+ )
+ if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
+ records = append(records,
+ fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, 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/autotls/autotls.go b/autotls/autotls.go
index 77c7b15601..4bbc229356 100644
--- a/autotls/autotls.go
+++ b/autotls/autotls.go
@@ -229,7 +229,6 @@ func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallback
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI)
},
- SessionTicketsDisabled: true,
}
}
diff --git a/backup.go b/backup.go
index eaa18d18b1..b8e172a9d2 100644
--- a/backup.go
+++ b/backup.go
@@ -288,6 +288,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil {
xerrx("writing moxversion", err)
}
+ backupDB(store.AuthDB, "auth.db")
backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
backupDB(dmarcdb.EvalDB, "dmarceval.db")
backupDB(mtastsdb.DB, "mtasts.db")
@@ -548,7 +549,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
}
switch p {
- case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
+ case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
// Already handled.
return nil
case "lastknownversion": // Optional file, not yet handled.
diff --git a/config/config.go b/config/config.go
index 43d6d29ffd..6b2f8e37ff 100644
--- a/config/config.go
+++ b/config/config.go
@@ -158,6 +158,8 @@ type Listener struct {
FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."`
+ TLSSessionTicketsDisabled *bool `sconf:"optional" sconf-doc:"Override default setting for enabling TLS session tickets. Disabling session tickets may work around TLS interoperability issues."`
+
DNSBLZones []dns.Domain `sconf:"-"`
} `sconf:"optional"`
Submission struct {
diff --git a/config/doc.go b/config/doc.go
index b9e82016a4..c7c91f23e2 100644
--- a/config/doc.go
+++ b/config/doc.go
@@ -262,6 +262,10 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# account. Default: 15s. (optional)
FirstTimeSenderDelay: 0s
+ # Override default setting for enabling TLS session tickets. Disabling session
+ # tickets may work around TLS interoperability issues. (optional)
+ TLSSessionTicketsDisabled: false
+
# SMTP for submitting email, e.g. by email applications. Starts out in plain text,
# can be upgraded to TLS with the STARTTLS command. Prefer using Submissions which
# is always a TLS connection. (optional)
diff --git a/ctl.go b/ctl.go
index 1722553e7d..0efeb2a0cb 100644
--- a/ctl.go
+++ b/ctl.go
@@ -2,6 +2,7 @@ package main
import (
"bufio"
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -21,6 +22,7 @@ import (
"github.com/mjl-/bstore"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
@@ -973,7 +975,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
localpart := ctl.xread()
d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain")
- err = mox.DomainAdd(ctx, d, account, smtp.Localpart(localpart))
+ err = admin.DomainAdd(ctx, d, account, smtp.Localpart(localpart))
ctl.xcheck(err, "adding domain")
ctl.xwriteok()
@@ -986,7 +988,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
domain := ctl.xread()
d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain")
- err = mox.DomainRemove(ctx, d)
+ err = admin.DomainRemove(ctx, d)
ctl.xcheck(err, "removing domain")
ctl.xwriteok()
@@ -999,7 +1001,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/
account := ctl.xread()
address := ctl.xread()
- err := mox.AccountAdd(ctx, account, address)
+ err := admin.AccountAdd(ctx, account, address)
ctl.xcheck(err, "adding account")
ctl.xwriteok()
@@ -1010,10 +1012,98 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error
*/
account := ctl.xread()
- err := mox.AccountRemove(ctx, account)
+ err := admin.AccountRemove(ctx, account)
ctl.xcheck(err, "removing account")
ctl.xwriteok()
+ case "tlspubkeylist":
+ /* protocol:
+ > "tlspubkeylist"
+ > account (or empty)
+ < "ok" or error
+ < stream
+ */
+ accountOpt := ctl.xread()
+ tlspubkeys, err := store.TLSPublicKeyList(ctx, accountOpt)
+ ctl.xcheck(err, "list tls public keys")
+ ctl.xwriteok()
+ xw := ctl.writer()
+ fmt.Fprintf(xw, "# fingerprint, type, name, account, login address, no imap preauth (%d)\n", len(tlspubkeys))
+ for _, k := range tlspubkeys {
+ fmt.Fprintf(xw, "%s\t%s\t%q\t%s\t%s\t%v\n", k.Fingerprint, k.Type, k.Name, k.Account, k.LoginAddress, k.NoIMAPPreauth)
+ }
+ xw.xclose()
+
+ case "tlspubkeyget":
+ /* protocol:
+ > "tlspubkeyget"
+ > fingerprint
+ < "ok" or error
+ < type
+ < name
+ < account
+ < address
+ < noimappreauth (true/false)
+ < stream (certder)
+ */
+ fp := ctl.xread()
+ tlspubkey, err := store.TLSPublicKeyGet(ctx, fp)
+ ctl.xcheck(err, "looking tls public key")
+ ctl.xwriteok()
+ ctl.xwrite(tlspubkey.Type)
+ ctl.xwrite(tlspubkey.Name)
+ ctl.xwrite(tlspubkey.Account)
+ ctl.xwrite(tlspubkey.LoginAddress)
+ ctl.xwrite(fmt.Sprintf("%v", tlspubkey.NoIMAPPreauth))
+ ctl.xstreamfrom(bytes.NewReader(tlspubkey.CertDER))
+
+ case "tlspubkeyadd":
+ /* protocol:
+ > "tlspubkeyadd"
+ > loginaddress
+ > name (or empty)
+ > noimappreauth (true/false)
+ > stream (certder)
+ < "ok" or error
+ */
+ loginAddress := ctl.xread()
+ name := ctl.xread()
+ noimappreauth := ctl.xread()
+ if noimappreauth != "true" && noimappreauth != "false" {
+ ctl.xcheck(fmt.Errorf("bad value %q", noimappreauth), "parsing noimappreauth")
+ }
+ var b bytes.Buffer
+ ctl.xstreamto(&b)
+ tlspubkey, err := store.ParseTLSPublicKeyCert(b.Bytes())
+ ctl.xcheck(err, "parsing certificate")
+ if name != "" {
+ tlspubkey.Name = name
+ }
+ acc, _, err := store.OpenEmail(ctl.log, loginAddress)
+ ctl.xcheck(err, "open account for address")
+ defer func() {
+ err := acc.Close()
+ ctl.log.Check(err, "close account")
+ }()
+ tlspubkey.Account = acc.Name
+ tlspubkey.LoginAddress = loginAddress
+ tlspubkey.NoIMAPPreauth = noimappreauth == "true"
+
+ err = store.TLSPublicKeyAdd(ctx, &tlspubkey)
+ ctl.xcheck(err, "adding tls public key")
+ ctl.xwriteok()
+
+ case "tlspubkeyrm":
+ /* protocol:
+ > "tlspubkeyadd"
+ > fingerprint
+ < "ok" or error
+ */
+ fp := ctl.xread()
+ err := store.TLSPublicKeyRemove(ctx, fp)
+ ctl.xcheck(err, "removing tls public key")
+ ctl.xwriteok()
+
case "addressadd":
/* protocol:
> "addressadd"
@@ -1023,7 +1113,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/
address := ctl.xread()
account := ctl.xread()
- err := mox.AddressAdd(ctx, address, account)
+ err := admin.AddressAdd(ctx, address, account)
ctl.xcheck(err, "adding address")
ctl.xwriteok()
@@ -1034,7 +1124,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error
*/
address := ctl.xread()
- err := mox.AddressRemove(ctx, address)
+ err := admin.AddressRemove(ctx, address)
ctl.xcheck(err, "removing address")
ctl.xwriteok()
@@ -1099,7 +1189,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var alias config.Alias
xparseJSON(ctl, line, &alias)
- err = mox.AliasAdd(ctx, addr, alias)
+ err = admin.AliasAdd(ctx, addr, alias)
ctl.xcheck(err, "adding alias")
ctl.xwriteok()
@@ -1118,7 +1208,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
allowmsgfrom := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
- err = mox.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
+ err = admin.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
a, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("alias does not exist")
@@ -1159,7 +1249,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
address := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
- err = mox.AliasRemove(ctx, addr)
+ err = admin.AliasRemove(ctx, addr)
ctl.xcheck(err, "removing alias")
ctl.xwriteok()
@@ -1176,7 +1266,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
- err = mox.AliasAddressesAdd(ctx, addr, addresses)
+ err = admin.AliasAddressesAdd(ctx, addr, addresses)
ctl.xcheck(err, "adding addresses to alias")
ctl.xwriteok()
@@ -1193,7 +1283,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
- err = mox.AliasAddressesRemove(ctx, addr, addresses)
+ err = admin.AliasAddressesRemove(ctx, addr, addresses)
ctl.xcheck(err, "removing addresses to alias")
ctl.xwriteok()
diff --git a/ctl_test.go b/ctl_test.go
index 53dee1b6de..1a7c8464c9 100644
--- a/ctl_test.go
+++ b/ctl_test.go
@@ -4,8 +4,12 @@ package main
import (
"context"
+ "crypto/ed25519"
+ cryptorand "crypto/rand"
+ "crypto/x509"
"flag"
"fmt"
+ "math/big"
"net"
"os"
"path/filepath"
@@ -50,6 +54,10 @@ func TestCtl(t *testing.T) {
tcheck(t, err, "queue init")
defer queue.Shutdown()
+ err = store.Init(ctxbg)
+ tcheck(t, err, "store init")
+ defer store.Close()
+
testctl := func(fn func(clientctl *ctl)) {
t.Helper()
@@ -334,6 +342,43 @@ func TestCtl(t *testing.T) {
ctlcmdConfigAliasRemove(ctl, "support@mox.example")
})
+ // accounttlspubkeyadd
+ certDER := fakeCert(t)
+ testctl(func(ctl *ctl) {
+ ctlcmdConfigTlspubkeyAdd(ctl, "mjl@mox.example", "testkey", false, certDER)
+ })
+
+ // "accounttlspubkeylist"
+ testctl(func(ctl *ctl) {
+ ctlcmdConfigTlspubkeyList(ctl, "")
+ })
+ testctl(func(ctl *ctl) {
+ ctlcmdConfigTlspubkeyList(ctl, "mjl")
+ })
+
+ tpkl, err := store.TLSPublicKeyList(ctxbg, "")
+ tcheck(t, err, "list tls public keys")
+ if len(tpkl) != 1 {
+ t.Fatalf("got %d tls public keys, expected 1", len(tpkl))
+ }
+ fingerprint := tpkl[0].Fingerprint
+
+ // "accounttlspubkeyget"
+ testctl(func(ctl *ctl) {
+ ctlcmdConfigTlspubkeyGet(ctl, fingerprint)
+ })
+
+ // "accounttlspubkeyrm"
+ testctl(func(ctl *ctl) {
+ ctlcmdConfigTlspubkeyRemove(ctl, fingerprint)
+ })
+
+ tpkl, err = store.TLSPublicKeyList(ctxbg, "")
+ tcheck(t, err, "list tls public keys")
+ if len(tpkl) != 0 {
+ t.Fatalf("got %d tls public keys, expected 0", len(tpkl))
+ }
+
// "loglevels"
testctl(func(ctl *ctl) {
ctlcmdLoglevels(ctl)
@@ -453,3 +498,15 @@ func TestCtl(t *testing.T) {
}
cmdVerifydata(&xcmd)
}
+
+func fakeCert(t *testing.T) []byte {
+ t.Helper()
+ seed := make([]byte, ed25519.SeedSize)
+ privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1), // Required field...
+ }
+ localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
+ tcheck(t, err, "making certificate")
+ return localCertBuf
+}
diff --git a/dmarc/dmarc.go b/dmarc/dmarc.go
index ccfc8b196e..3b59ca49e6 100644
--- a/dmarc/dmarc.go
+++ b/dmarc/dmarc.go
@@ -15,7 +15,7 @@ import (
"errors"
"fmt"
"log/slog"
- mathrand "math/rand"
+ mathrand2 "math/rand/v2"
"time"
"github.com/mjl-/mox/dkim"
@@ -257,7 +257,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFr
// Record can request sampling of messages to apply policy.
// See ../rfc/7489:1432
- useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage
+ useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand2.IntN(100) < record.Percentage
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
// from reject to quarantine if this message was sampled out.
diff --git a/dmarcdb/eval.go b/dmarcdb/eval.go
index 9b893e18f6..1740f31ef6 100644
--- a/dmarcdb/eval.go
+++ b/dmarcdb/eval.go
@@ -252,7 +252,7 @@ var jitterRand = mox.NewPseudoRand()
// Jitter so we don't cause load at exactly whole hours, other processes may
// already be doing that.
var jitteredTimeUntil = func(t time.Time) time.Duration {
- return time.Until(t.Add(time.Duration(30+jitterRand.Intn(60)) * time.Second))
+ return time.Until(t.Add(time.Duration(30+jitterRand.IntN(60)) * time.Second))
}
// Start launches a goroutine that wakes up at each whole hour (plus jitter) and
diff --git a/doc.go b/doc.go
index 9498ee0f58..cc262871fc 100644
--- a/doc.go
+++ b/doc.go
@@ -69,6 +69,11 @@ any parameters. Followed by the help and usage information for each command.
mox config address rm address
mox config domain add domain account [localpart]
mox config domain rm domain
+ mox config tlspubkey list [account]
+ mox config tlspubkey get fingerprint
+ mox config tlspubkey add address [name] < cert.pem
+ mox config tlspubkey rm fingerprint
+ mox config tlspubkey gen stem
mox config alias list domain
mox config alias print alias
mox config alias add alias@domain rcpt1@domain ...
@@ -994,6 +999,61 @@ rejected.
usage: mox config domain rm domain
+# mox config tlspubkey list
+
+List TLS public keys for TLS client certificate authentication.
+
+If account is absent, the TLS public keys for all accounts are listed.
+
+ usage: mox config tlspubkey list [account]
+
+# mox config tlspubkey get
+
+Get a TLS public key for a fingerprint.
+
+Prints the type, name, account and address for the key, and the certificate in
+PEM format.
+
+ usage: mox config tlspubkey get fingerprint
+
+# mox config tlspubkey add
+
+Add a TLS public key to the account of the given address.
+
+The public key is read from the certificate.
+
+The optional name is a human-readable descriptive name of the key. If absent,
+the CommonName from the certificate is used.
+
+ usage: mox config tlspubkey add address [name] < cert.pem
+ -no-imap-preauth
+ Don't automatically switch new IMAP connections authenticated with this key to "authenticated" state after the TLS handshake. For working around clients that ignore the untagged IMAP PREAUTH response and try to authenticate while already authenticated.
+
+# mox config tlspubkey rm
+
+Remove TLS public key for fingerprint.
+
+ usage: mox config tlspubkey rm fingerprint
+
+# mox config tlspubkey gen
+
+Generate an ed25519 private key and minimal certificate for use a TLS public key and write to files starting with stem.
+
+The private key is written to $stem.$timestamp.ed25519privatekey.pkcs8.pem.
+The certificate is written to $stem.$timestamp.certificate.pem.
+The private key and certificate are also written to
+$stem.$timestamp.ed25519privatekey-certificate.pem.
+
+The certificate can be added to an account with "mox config account tlspubkey add".
+
+The combined file can be used with "mox sendmail".
+
+The private key is also written to standard error in raw-url-base64-encoded
+form, also for use with "mox sendmail". The fingerprint is written to standard
+error too, for reference.
+
+ usage: mox config tlspubkey gen stem
+
# mox config alias list
List aliases for domain.
diff --git a/gentestdata.go b/gentestdata.go
index b71bfa8c4c..544c038416 100644
--- a/gentestdata.go
+++ b/gentestdata.go
@@ -187,6 +187,12 @@ Accounts:
err = os.WriteFile(filepath.Join(destDataDir, "moxversion"), []byte(moxvar.Version), 0660)
xcheckf(err, "writing moxversion")
+ // Populate auth.db
+ err = store.Init(ctxbg)
+ xcheckf(err, "store init")
+ err = store.TLSPublicKeyAdd(ctxbg, &store.TLSPublicKey{Fingerprint: "...", Type: "ecdsa-p256", CertDER: []byte("..."), Account: "test0", LoginAddress: "test0@mox.example"})
+ xcheckf(err, "adding tlspubkey")
+
// Populate dmarc.db.
err = dmarcdb.Init()
xcheckf(err, "dmarcdb init")
diff --git a/http/autoconf.go b/http/autoconf.go
index 3c877e44fe..25359b65fa 100644
--- a/http/autoconf.go
+++ b/http/autoconf.go
@@ -11,7 +11,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto"
"rsc.io/qr"
- "github.com/mjl-/mox/mox-"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp"
)
@@ -70,13 +70,13 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
return
}
- socketType := func(tlsMode mox.TLSMode) (string, error) {
+ socketType := func(tlsMode admin.TLSMode) (string, error) {
switch tlsMode {
- case mox.TLSModeImmediate:
+ case admin.TLSModeImmediate:
return "SSL", nil
- case mox.TLSModeSTARTTLS:
+ case admin.TLSModeSTARTTLS:
return "STARTTLS", nil
- case mox.TLSModeNone:
+ case admin.TLSModeNone:
return "plain", nil
default:
return "", fmt.Errorf("unknown tls mode %v", tlsMode)
@@ -84,7 +84,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
}
var imapTLS, submissionTLS string
- config, err := mox.ClientConfigDomain(addr.Domain)
+ config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil {
imapTLS, err = socketType(config.IMAP.TLSMode)
}
@@ -105,6 +105,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
resp.EmailProvider.DisplayShortName = addr.Domain.ASCII
// todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then.
+ // todo: let user configure they prefer or require tls client auth and specify "TLS-client-cert"
resp.EmailProvider.IncomingServer.Type = "imap"
resp.EmailProvider.IncomingServer.Hostname = config.IMAP.Host.ASCII
@@ -170,13 +171,13 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
}
// tlsmode returns the "ssl" and "encryption" fields.
- tlsmode := func(tlsMode mox.TLSMode) (string, string, error) {
+ tlsmode := func(tlsMode admin.TLSMode) (string, string, error) {
switch tlsMode {
- case mox.TLSModeImmediate:
+ case admin.TLSModeImmediate:
return "on", "TLS", nil
- case mox.TLSModeSTARTTLS:
+ case admin.TLSModeSTARTTLS:
return "on", "", nil
- case mox.TLSModeNone:
+ case admin.TLSModeNone:
return "off", "", nil
default:
return "", "", fmt.Errorf("unknown tls mode %v", tlsMode)
@@ -185,7 +186,7 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
var imapSSL, imapEncryption string
var submissionSSL, submissionEncryption string
- config, err := mox.ClientConfigDomain(addr.Domain)
+ config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil {
imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode)
}
@@ -208,6 +209,8 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ // todo: let user configure they prefer or require tls client auth and add "AuthPackage" with value "certificate" to Protocol? see https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
+
resp := autodiscoverResponse{}
resp.XMLName.Local = "Autodiscover"
resp.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"
diff --git a/http/mobileconfig.go b/http/mobileconfig.go
index 373573ef51..af29fa6c49 100644
--- a/http/mobileconfig.go
+++ b/http/mobileconfig.go
@@ -12,7 +12,7 @@ import (
"golang.org/x/exp/maps"
- "github.com/mjl-/mox/mox-"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp"
)
@@ -122,7 +122,7 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
return nil, fmt.Errorf("parsing address: %v", err)
}
- config, err := mox.ClientConfigDomain(addr.Domain)
+ config, err := admin.ClientConfigDomain(addr.Domain)
if err != nil {
return nil, fmt.Errorf("getting config for domain: %v", err)
}
@@ -175,12 +175,12 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
"IncomingMailServerUsername": addresses[0],
"IncomingMailServerHostName": config.IMAP.Host.ASCII,
"IncomingMailServerPortNumber": config.IMAP.Port,
- "IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate,
+ "IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate,
"OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
"OutgoingMailServerHostName": config.Submission.Host.ASCII,
"OutgoingMailServerPortNumber": config.Submission.Port,
"OutgoingMailServerUsername": addresses[0],
- "OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate,
+ "OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate,
"OutgoingPasswordSameAsIncomingPassword": true,
"PayloadIdentifier": reverseAddr + ".email.account",
"PayloadType": "com.apple.mail.managed",
diff --git a/imapclient/client.go b/imapclient/client.go
index 6f45fda2fe..019a275158 100644
--- a/imapclient/client.go
+++ b/imapclient/client.go
@@ -32,6 +32,7 @@ type Conn struct {
record bool // If true, bytes read are added to recordBuf. recorded() resets.
recordBuf []byte
+ Preauth bool
LastTag string
CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code.
CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command.
@@ -53,7 +54,9 @@ func (e Error) Unwrap() error {
// If xpanic is true, functions that would return an error instead panic. For parse
// errors, the resulting stack traces show typically show what was being parsed.
//
-// The initial untagged greeting response is read and must be "OK".
+// The initial untagged greeting response is read and must be "OK" or
+// "PREAUTH". If preauth, the connection is already in authenticated state,
+// typically through TLS client certificate. This is indicated in Conn.Preauth.
func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) {
c := Conn{
conn: conn,
@@ -77,7 +80,8 @@ func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) {
}
return &c, nil
case UntaggedPreauth:
- c.xerrorf("greeting: unexpected preauth")
+ c.Preauth = true
+ return &c, nil
case UntaggedBye:
c.xerrorf("greeting: server sent bye")
default:
diff --git a/imapserver/authenticate_test.go b/imapserver/authenticate_test.go
index f2e0c54e1c..6bba7f251f 100644
--- a/imapserver/authenticate_test.go
+++ b/imapserver/authenticate_test.go
@@ -1,20 +1,28 @@
package imapserver
import (
+ "context"
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
+ "crypto/tls"
"encoding/base64"
"errors"
"fmt"
"hash"
+ "net"
+ "os"
+ "path/filepath"
"strings"
"testing"
+ "time"
"golang.org/x/text/secure/precis"
+ "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/scram"
+ "github.com/mjl-/mox/store"
)
func TestAuthenticateLogin(t *testing.T) {
@@ -210,3 +218,149 @@ func TestAuthenticateCRAMMD5(t *testing.T) {
auth("ok", "mo\u0301x@mox.example", password1)
tc.close()
}
+
+func TestAuthenticateTLSClientCert(t *testing.T) {
+ tc := startArgs(t, true, true, true, true, "mjl")
+ tc.transactf("no", "authenticate external ") // No TLS auth.
+ tc.close()
+
+ // Create a certificate, register its public key with account, and make a tls
+ // client config that sends the certificate.
+ clientCert0 := fakeCert(t, true)
+ clientConfig := tls.Config{
+ InsecureSkipVerify: true,
+ Certificates: []tls.Certificate{clientCert0},
+ }
+
+ tlspubkey, err := store.ParseTLSPublicKeyCert(clientCert0.Certificate[0])
+ tcheck(t, err, "parse certificate")
+ tlspubkey.Account = "mjl"
+ tlspubkey.LoginAddress = "mjl@mox.example"
+ tlspubkey.NoIMAPPreauth = true
+
+ addClientCert := func() error {
+ return store.TLSPublicKeyAdd(ctxbg, &tlspubkey)
+ }
+
+ // No preauth, explicit authenticate with TLS.
+ tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ if tc.client.Preauth {
+ t.Fatalf("preauthentication while not configured for tls public key")
+ }
+ tc.transactf("ok", "authenticate external ")
+ tc.close()
+
+ // External with explicit username.
+ tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ if tc.client.Preauth {
+ t.Fatalf("preauthentication while not configured for tls public key")
+ }
+ tc.transactf("ok", "authenticate external %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example")))
+ tc.close()
+
+ // No preauth, also allow other mechanisms.
+ tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
+ tc.close()
+
+ // No preauth, also allow other username for same account.
+ tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000móx@mox.example\u0000"+password0)))
+ tc.close()
+
+ // No preauth, other mechanism must be for same account.
+ acc, err := store.OpenAccount(pkglog, "other")
+ tcheck(t, err, "open account")
+ err = acc.SetPassword(pkglog, "test1234")
+ tcheck(t, err, "set password")
+ tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000other@mox.example\u0000test1234")))
+ tc.close()
+
+ // Starttls and external auth.
+ tc = startArgsMore(t, true, false, nil, &clientConfig, false, true, true, "mjl", addClientCert)
+ tc.client.Starttls(&clientConfig)
+ tc.transactf("ok", "authenticate external =")
+ tc.close()
+
+ tlspubkey.NoIMAPPreauth = false
+ err = store.TLSPublicKeyUpdate(ctxbg, &tlspubkey)
+ tcheck(t, err, "update tls public key")
+
+ // With preauth, no authenticate command needed/allowed.
+ // Already set up tls session ticket cache, for next test.
+ serverConfig := tls.Config{
+ Certificates: []tls.Certificate{fakeCert(t, false)},
+ }
+ ctx, cancel := context.WithCancel(ctxbg)
+ defer cancel()
+ mox.StartTLSSessionTicketKeyRefresher(ctx, pkglog, &serverConfig)
+ clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10)
+ tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, true, "mjl", addClientCert)
+ if !tc.client.Preauth {
+ t.Fatalf("not preauthentication while configured for tls public key")
+ }
+ cs := tc.conn.(*tls.Conn).ConnectionState()
+ if cs.DidResume {
+ t.Fatalf("tls connection was resumed")
+ }
+ tc.transactf("no", "authenticate external ") // Not allowed, already in authenticated state.
+ tc.close()
+
+ // Authentication works with TLS resumption.
+ tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, true, "mjl", addClientCert)
+ if !tc.client.Preauth {
+ t.Fatalf("not preauthentication while configured for tls public key")
+ }
+ cs = tc.conn.(*tls.Conn).ConnectionState()
+ if !cs.DidResume {
+ t.Fatalf("tls connection was not resumed")
+ }
+ // Check that operations that require an account work.
+ tc.client.Enable("imap4rev2")
+ received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00")
+ tc.check(err, "parse time")
+ tc.client.Append("inbox", nil, &received, []byte(exampleMsg))
+ tc.client.Select("inbox")
+ tc.close()
+
+ // Authentication with unknown key should fail.
+ // todo: less duplication, change startArgs so this can be merged into it.
+ err = store.Close()
+ tcheck(t, err, "store close")
+ os.RemoveAll("../testdata/imap/data")
+ err = store.Init(ctxbg)
+ tcheck(t, err, "store init")
+ mox.Context = ctxbg
+ mox.ConfigStaticPath = filepath.FromSlash("../testdata/imap/mox.conf")
+ mox.MustLoadConfig(true, false)
+ switchStop := store.Switchboard()
+ defer switchStop()
+
+ serverConn, clientConn := net.Pipe()
+ defer clientConn.Close()
+
+ done := make(chan struct{})
+ defer func() { <-done }()
+ connCounter++
+ cid := connCounter
+ go func() {
+ defer serverConn.Close()
+ serve("test", cid, &serverConfig, serverConn, true, false)
+ close(done)
+ }()
+
+ clientConfig.ClientSessionCache = nil
+ clientConn = tls.Client(clientConn, &clientConfig)
+ // note: It's not enough to do a handshake and check if that was successful. If the
+ // client cert is not acceptable, we only learn after the handshake, when the first
+ // data messages are exchanged.
+ buf := make([]byte, 100)
+ _, err = clientConn.Read(buf)
+ if err == nil {
+ t.Fatalf("tls handshake with unknown client certificate succeeded")
+ }
+ if alert, ok := mox.AsTLSAlert(err); !ok || alert != 42 {
+ t.Fatalf("got err %#v, expected tls 'bad certificate' alert", err)
+ }
+}
diff --git a/imapserver/server.go b/imapserver/server.go
index 9fe6c14310..2d3861631b 100644
--- a/imapserver/server.go
+++ b/imapserver/server.go
@@ -39,6 +39,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
+ "crypto/x509"
"encoding/base64"
"errors"
"fmt"
@@ -148,6 +149,7 @@ var authFailDelay = time.Second // After authentication failure.
// SPECIAL-USE: ../rfc/6154
// LIST-STATUS: ../rfc/5819
// ID: ../rfc/2971
+// AUTH=EXTERNAL: ../rfc/4422:1575
// AUTH=SCRAM-SHA-256-PLUS and AUTH=SCRAM-SHA-256: ../rfc/7677 ../rfc/5802
// AUTH=SCRAM-SHA-1-PLUS and AUTH=SCRAM-SHA-1: ../rfc/5802
// AUTH=CRAM-MD5: ../rfc/2195
@@ -176,7 +178,7 @@ type conn struct {
tw *moxio.TraceWriter
slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy.
lastlog time.Time // For printing time since previous log line.
- tlsConfig *tls.Config // TLS config to use for handshake.
+ baseTLSConfig *tls.Config // Base TLS config to use for handshake.
remoteIP net.IP
noRequireSTARTTLS bool
cmd string // Currently executing, for deciding to applyChanges and logging.
@@ -194,8 +196,12 @@ type conn struct {
// ../rfc/5182:13 ../rfc/9051:4040
searchResult []store.UID
- // Only when authenticated.
+ // Only set when connection has been authenticated. These can be set even when
+ // c.state is stateNotAuthenticated, for TLS client certificate authentication. In
+ // that case, credentials aren't used until the authentication command with the
+ // SASL "EXTERNAL" mechanism.
authFailed int // Number of failed auth attempts. For slowing down remote with many failures.
+ noPreauth bool // If set, don't switch connection to "authenticated" after TLS handshake with client certificate authentication.
username string // Full username as used during login.
account *store.Account
comm *store.Comm // For sending/receiving changes on mailboxes in account, e.g. from messages incoming on smtp, or another imap client.
@@ -365,8 +371,14 @@ func listen1(protocol, listenerName, ip string, port int, tlsConfig *tls.Config,
if err != nil {
log.Fatalx("imap: listen for imap", err, slog.String("protocol", protocol), slog.String("listener", listenerName))
}
- if xtls {
- ln = tls.NewListener(ln, tlsConfig)
+
+ // Each listener gets its own copy of the config, so session keys between different
+ // ports on same listener aren't shared. We rotate session keys explicitly in this
+ // base TLS config because each connection clones the TLS config before using. The
+ // base TLS config would never get automatically managed/rotated session keys.
+ if tlsConfig != nil {
+ tlsConfig = tlsConfig.Clone()
+ mox.StartTLSSessionTicketKeyRefresher(mox.Shutdown, log, tlsConfig)
}
serve := func() {
@@ -647,7 +659,7 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x
conn: nc,
tls: xtls,
lastlog: time.Now(),
- tlsConfig: tlsConfig,
+ baseTLSConfig: tlsConfig,
remoteIP: remoteIP,
noRequireSTARTTLS: noRequireSTARTTLS,
enabled: map[capability]bool{},
@@ -670,19 +682,15 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x
return l
})
c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn)
- c.tw = moxio.NewTraceWriter(c.log, "S: ", c)
// todo: tracing should be done on whatever comes out of c.br. the remote connection write a command plus data, and bufio can read it in one read, causing a command parser that sets the tracing level to data to have no effect. we are now typically logging sent messages, when mail clients append to the Sent mailbox.
c.br = bufio.NewReader(c.tr)
+ c.tw = moxio.NewTraceWriter(c.log, "S: ", c)
c.bw = bufio.NewWriter(c.tw)
// Many IMAP connections use IDLE to wait for new incoming messages. We'll enable
// keepalive to get a higher chance of the connection staying alive, or otherwise
// detecting broken connections early.
- xconn := c.conn
- if xtls {
- xconn = c.conn.(*tls.Conn).NetConn()
- }
- if tcpconn, ok := xconn.(*net.TCPConn); ok {
+ if tcpconn, ok := c.conn.(*net.TCPConn); ok {
if err := tcpconn.SetKeepAlivePeriod(5 * time.Minute); err != nil {
c.log.Errorx("setting keepalive period", err)
} else if err := tcpconn.SetKeepAlive(true); err != nil {
@@ -719,6 +727,12 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x
}
}()
+ if xtls {
+ // Start TLS on connection. We perform the handshake explicitly, so we can set a
+ // timeout, do client certificate authentication, log TLS details afterwards.
+ c.xtlsHandshakeAndAuthenticate(c.conn)
+ }
+
select {
case <-mox.Shutdown.Done():
// ../rfc/9051:5381
@@ -752,7 +766,12 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x
mox.Connections.Register(nc, "imap", listenerName)
defer mox.Connections.Unregister(nc)
- c.writelinef("* OK [CAPABILITY %s] mox imap", c.capabilities())
+ if c.account != nil && !c.noPreauth {
+ c.state = stateAuthenticated
+ c.writelinef("* PREAUTH [CAPABILITY %s] mox imap welcomes %s", c.capabilities(), c.username)
+ } else {
+ c.writelinef("* OK [CAPABILITY %s] mox imap", c.capabilities())
+ }
for {
c.command()
@@ -766,6 +785,172 @@ func isClosed(err error) bool {
return errors.Is(err, errIO) || errors.Is(err, errProtocol) || moxio.IsClosed(err)
}
+// makeTLSConfig makes a new tls config that is bound to the connection for
+// possible client certificate authentication.
+func (c *conn) makeTLSConfig() *tls.Config {
+ // We clone the config so we can set VerifyPeerCertificate below to a method bound
+ // to this connection. Earlier, we set session keys explicitly on the base TLS
+ // config, so they can be used for this connection too.
+ tlsConf := c.baseTLSConfig.Clone()
+
+ // Allow client certificate authentication, for use with the sasl "external"
+ // authentication mechanism.
+ tlsConf.ClientAuth = tls.RequestClientCert
+
+ // We verify the client certificate during the handshake. The TLS handshake is
+ // initiated explicitly for incoming connections and during starttls, so we can
+ // immediately extract the account name and address used for authentication.
+ tlsConf.VerifyPeerCertificate = c.tlsClientAuthVerifyPeerCert
+
+ return tlsConf
+}
+
+// tlsClientAuthVerifyPeerCert can be used as tls.Config.VerifyPeerCertificate, and
+// sets authentication-related fields on conn. This is not called on resumed TLS
+// connections.
+func (c *conn) tlsClientAuthVerifyPeerCert(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+ if len(rawCerts) == 0 {
+ return nil
+ }
+
+ // If we had too many authentication failures from this IP, don't attempt
+ // authentication. If this is a new incoming connetion, it is closed after the TLS
+ // handshake.
+ if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) {
+ return nil
+ }
+
+ cert, err := x509.ParseCertificate(rawCerts[0])
+ if err != nil {
+ c.log.Debugx("parsing tls client certificate", err)
+ return err
+ }
+ if err := c.tlsClientAuthVerifyPeerCertParsed(cert); err != nil {
+ c.log.Debugx("verifying tls client certificate", err)
+ return fmt.Errorf("verifying client certificate: %w", err)
+ }
+ return nil
+}
+
+// tlsClientAuthVerifyPeerCertParsed verifies a client certificate. Called both for
+// fresh and resumed TLS connections.
+func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error {
+ if c.account != nil {
+ return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication")
+ }
+
+ authResult := "error"
+ defer func() {
+ metrics.AuthenticationInc("imap", "tlsclientauth", authResult)
+ if authResult == "ok" {
+ mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
+ } else {
+ mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1)
+ }
+ }()
+
+ // For many failed auth attempts, slow down verification attempts.
+ if c.authFailed > 3 && authFailDelay > 0 {
+ mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*authFailDelay)
+ }
+ c.authFailed++ // Compensated on success.
+ defer func() {
+ // On the 3rd failed authentication, start responding slowly. Successful auth will
+ // cause fast responses again.
+ if c.authFailed >= 3 {
+ c.setSlow(true)
+ }
+ }()
+
+ shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
+ fp := base64.RawURLEncoding.EncodeToString(shabuf[:])
+ pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp)
+ if err != nil {
+ if err == bstore.ErrAbsent {
+ authResult = "badcreds"
+ }
+ return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err)
+ }
+
+ // Verify account exists and still matches address.
+ acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress)
+ if err != nil {
+ return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err)
+ }
+ defer func() {
+ if acc != nil {
+ err := acc.Close()
+ c.xsanity(err, "close account")
+ }
+ }()
+ if acc.Name != pubKey.Account {
+ return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name)
+ }
+
+ authResult = "ok"
+ c.authFailed = 0
+ c.noPreauth = pubKey.NoIMAPPreauth
+ c.account = acc
+ acc = nil // Prevent cleanup by defer.
+ c.username = pubKey.LoginAddress
+ c.comm = store.RegisterComm(c.account)
+ c.log.Debug("tls client authenticated with client certificate",
+ slog.String("fingerprint", fp),
+ slog.String("username", c.username),
+ slog.String("account", c.account.Name),
+ slog.Any("remote", c.remoteIP))
+ return nil
+}
+
+// xtlsHandshakeAndAuthenticate performs the TLS handshake, and verifies a client
+// certificate if present.
+func (c *conn) xtlsHandshakeAndAuthenticate(conn net.Conn) {
+ tlsConn := tls.Server(conn, c.makeTLSConfig())
+ c.conn = tlsConn
+ c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn)
+ c.br = bufio.NewReader(c.tr)
+
+ cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid)
+ ctx, cancel := context.WithTimeout(cidctx, time.Minute)
+ defer cancel()
+ c.log.Debug("starting tls server handshake")
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ panic(fmt.Errorf("tls handshake: %s (%w)", err, errIO))
+ }
+ cancel()
+
+ cs := tlsConn.ConnectionState()
+ if cs.DidResume && len(cs.PeerCertificates) > 0 {
+ // Verify client after session resumption.
+ err := c.tlsClientAuthVerifyPeerCertParsed(cs.PeerCertificates[0])
+ if err != nil {
+ c.bwritelinef("* BYE [ALERT] Error verifying client certificate after TLS session resumption: %s", err)
+ panic(fmt.Errorf("tls verify client certificate after resumption: %s (%w)", err, errIO))
+ }
+ }
+
+ attrs := []slog.Attr{
+ slog.Any("version", tlsVersion(cs.Version)),
+ slog.String("ciphersuite", tls.CipherSuiteName(cs.CipherSuite)),
+ slog.String("sni", cs.ServerName),
+ slog.Bool("resumed", cs.DidResume),
+ slog.Int("clientcerts", len(cs.PeerCertificates)),
+ }
+ if c.account != nil {
+ attrs = append(attrs,
+ slog.String("account", c.account.Name),
+ slog.String("username", c.username),
+ )
+ }
+ c.log.Debug("tls handshake completed", attrs...)
+}
+
+type tlsVersion uint16
+
+func (v tlsVersion) String() string {
+ return strings.ReplaceAll(strings.ToLower(tls.VersionName(uint16(v))), " ", "-")
+}
+
func (c *conn) command() {
var tag, cmd, cmdlow string
var p *parser
@@ -1371,7 +1556,7 @@ func (c *conn) capabilities() string {
caps := serverCapabilities
// ../rfc/9051:1238
// We only allow starting without TLS when explicitly configured, in violation of RFC.
- if !c.tls && c.tlsConfig != nil {
+ if !c.tls && c.baseTLSConfig != nil {
caps += " STARTTLS"
}
if c.tls || c.noRequireSTARTTLS {
@@ -1379,6 +1564,9 @@ func (c *conn) capabilities() string {
} else {
caps += " LOGINDISABLED"
}
+ if c.tls && len(c.conn.(*tls.Conn).ConnectionState().PeerCertificates) > 0 {
+ caps += " AUTH=EXTERNAL"
+ }
return caps
}
@@ -1464,7 +1652,7 @@ func (c *conn) cmdStarttls(tag, cmd string, p *parser) {
if c.tls {
xsyntaxErrorf("tls already active") // ../rfc/9051:1353
}
- if c.tlsConfig == nil {
+ if c.baseTLSConfig == nil {
xsyntaxErrorf("starttls not announced")
}
@@ -1478,30 +1666,20 @@ func (c *conn) cmdStarttls(tag, cmd string, p *parser) {
// We add the cid to facilitate debugging in case of TLS connection failure.
c.ok(tag, cmd+" ("+mox.ReceivedID(c.cid)+")")
- cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid)
- ctx, cancel := context.WithTimeout(cidctx, time.Minute)
- defer cancel()
- tlsConn := tls.Server(conn, c.tlsConfig)
- c.log.Debug("starting tls server handshake")
- if err := tlsConn.HandshakeContext(ctx); err != nil {
- panic(fmt.Errorf("starttls handshake: %s (%w)", err, errIO))
- }
- cancel()
- tlsversion, ciphersuite := moxio.TLSInfo(tlsConn)
- c.log.Debug("tls server handshake done", slog.String("tls", tlsversion), slog.String("ciphersuite", ciphersuite))
-
- c.conn = tlsConn
- c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn)
- c.tw = moxio.NewTraceWriter(c.log, "S: ", c)
- c.br = bufio.NewReader(c.tr)
- c.bw = bufio.NewWriter(c.tw)
+ c.xtlsHandshakeAndAuthenticate(conn)
c.tls = true
+
+ // We are not sending unsolicited CAPABILITIES for newly available authentication
+ // mechanisms, clients can't depend on us sending it and should ask it themselves.
+ // ../rfc/9051:1382
}
// Authenticate using SASL. Supports multiple back and forths between client and
// server to finish authentication, unlike LOGIN which is just a single
// username/password.
//
+// We may already have ambient TLS credentials that have not been activated.
+//
// Status: Not authenticated.
func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
// Command: ../rfc/9051:1403 ../rfc/3501:1519
@@ -1529,7 +1707,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
}
}()
- var authVariant string
+ var authVariant string // Only known strings, used in metrics.
authResult := "error"
defer func() {
metrics.AuthenticationInc("imap", authVariant, authResult)
@@ -1583,6 +1761,18 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
return buf
}
+ // The various authentication mechanisms set account and username. We may already
+ // have an account and username from TLS client authentication. Afterwards, we
+ // check that the account is the same.
+ var account *store.Account
+ var username string
+ defer func() {
+ if account != nil {
+ err := account.Close()
+ c.xsanity(err, "close account")
+ }
+ }()
+
switch strings.ToUpper(authType) {
case "PLAIN":
authVariant = "plain"
@@ -1601,24 +1791,23 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xsyntaxErrorf("bad plain auth data, expected 3 nul-separated tokens, got %d tokens", len(plain))
}
authz := string(plain[0])
- authc := string(plain[1])
+ username = string(plain[1])
password := string(plain[2])
- if authz != "" && authz != authc {
+ if authz != "" && authz != username {
xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role")
}
- acc, err := store.OpenEmailAuth(c.log, authc, password)
+ var err error
+ account, err = store.OpenEmailAuth(c.log, username, password)
if err != nil {
if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds"
- c.log.Info("authentication failed", slog.String("username", authc))
+ c.log.Info("authentication failed", slog.String("username", username))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
xusercodeErrorf("", "error")
}
- c.account = acc
- c.username = authc
case "CRAM-MD5":
authVariant = strings.ToLower(authType)
@@ -1635,28 +1824,23 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
if len(t) != 2 || len(t[1]) != 2*md5.Size {
xsyntaxErrorf("malformed cram-md5 response")
}
- addr := t[0]
- c.log.Debug("cram-md5 auth", slog.String("address", addr))
- acc, _, err := store.OpenEmail(c.log, addr)
+ username = t[0]
+ c.log.Debug("cram-md5 auth", slog.String("address", username))
+ var err error
+ account, _, err = store.OpenEmail(c.log, username)
if err != nil {
if errors.Is(err, store.ErrUnknownCredentials) {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
xserverErrorf("looking up address: %v", err)
}
- defer func() {
- if acc != nil {
- err := acc.Close()
- c.xsanity(err, "close account")
- }
- }()
var ipadhash, opadhash hash.Hash
- acc.WithRLock(func() {
- err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
+ account.WithRLock(func() {
+ err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
password, err := bstore.QueryTx[store.Password](tx).Get()
if err == bstore.ErrAbsent {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
if err != nil {
@@ -1670,8 +1854,8 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xcheckf(err, "tx read")
})
if ipadhash == nil || opadhash == nil {
- c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", addr))
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
missingDerivedSecrets = true
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
@@ -1681,14 +1865,10 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
opadhash.Write(ipadhash.Sum(nil))
digest := fmt.Sprintf("%x", opadhash.Sum(nil))
if digest != t[1] {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
- c.account = acc
- acc = nil // Cancel cleanup.
- c.username = addr
-
case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1":
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
@@ -1721,29 +1901,24 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
c.log.Infox("scram protocol error", err, slog.Any("remote", c.remoteIP))
xuserErrorf("scram protocol error: %s", err)
}
- c.log.Debug("scram auth", slog.String("authentication", ss.Authentication))
- acc, _, err := store.OpenEmail(c.log, ss.Authentication)
+ username = ss.Authentication
+ c.log.Debug("scram auth", slog.String("authentication", username))
+ account, _, err = store.OpenEmail(c.log, username)
if err != nil {
// todo: we could continue scram with a generated salt, deterministically generated
// from the username. that way we don't have to store anything but attackers cannot
// learn if an account exists. same for absent scram saltedpassword below.
xuserErrorf("scram not possible")
}
- defer func() {
- if acc != nil {
- err := acc.Close()
- c.xsanity(err, "close account")
- }
- }()
- if ss.Authorization != "" && ss.Authorization != ss.Authentication {
+ if ss.Authorization != "" && ss.Authorization != username {
xuserErrorf("authentication with authorization for different user not supported")
}
var xscram store.SCRAM
- acc.WithRLock(func() {
- err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
+ account.WithRLock(func() {
+ err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
password, err := bstore.QueryTx[store.Password](tx).Get()
if err == bstore.ErrAbsent {
- c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
}
xcheckf(err, "fetching credentials")
@@ -1757,7 +1932,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
}
if len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0 {
missingDerivedSecrets = true
- c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", ss.Authentication))
+ c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username))
xuserErrorf("scram not possible")
}
return nil
@@ -1776,14 +1951,14 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
c.readline(false) // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) {
authResult = "badcreds"
- c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
} else if errors.Is(err, scram.ErrChannelBindingsDontMatch) {
authResult = "badchanbind"
- c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
+ c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "channel bindings do not match, potential mitm")
} else if errors.Is(err, scram.ErrInvalidEncoding) {
- c.log.Infox("bad scram protocol message", err, slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
+ c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP))
xuserErrorf("bad scram protocol message: %s", err)
}
xuserErrorf("server final: %w", err)
@@ -1793,18 +1968,65 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
// The message should be empty. todo: should we require it is empty?
xreadContinuation()
- c.account = acc
- acc = nil // Cancel cleanup.
- c.username = ss.Authentication
+ case "EXTERNAL":
+ authVariant = strings.ToLower(authType)
+
+ // ../rfc/4422:1618
+ buf := xreadInitial()
+ username = string(buf)
+
+ if !c.tls {
+ xusercodeErrorf("AUTHENTICATIONFAILED", "tls required for tls client certificate authentication")
+ }
+ if c.account == nil {
+ xusercodeErrorf("AUTHENTICATIONFAILED", "missing client certificate, required for tls client certificate authentication")
+ }
+
+ if username == "" {
+ username = c.username
+ }
+ var err error
+ account, _, err = store.OpenEmail(c.log, username)
+ xcheckf(err, "looking up username from tls client authentication")
default:
xuserErrorf("method not supported")
}
+ // We may already have TLS credentials. They won't have been enabled, or we could
+ // get here due to the state machine that doesn't allow authentication while being
+ // authenticated. But allow another SASL authentication, but it has to be for the
+ // same account. It can be for a different username (email address) of the account.
+ if c.account != nil {
+ if account != c.account {
+ c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection",
+ slog.String("saslmechanism", authVariant),
+ slog.String("saslaccount", account.Name),
+ slog.String("tlsaccount", c.account.Name),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ )
+ xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account")
+ } else if username != c.username {
+ c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username",
+ slog.String("saslmechanism", authVariant),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ slog.String("account", c.account.Name),
+ )
+ }
+ } else {
+ c.account = account
+ account = nil // Prevent cleanup.
+ }
+ c.username = username
+ if c.comm == nil {
+ c.comm = store.RegisterComm(c.account)
+ }
+
c.setSlow(false)
authResult = "ok"
c.authFailed = 0
- c.comm = store.RegisterComm(c.account)
c.state = stateAuthenticated
c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities())
}
@@ -1818,13 +2040,18 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) {
authResult := "error"
defer func() {
metrics.AuthenticationInc("imap", "login", authResult)
+ if authResult == "ok" {
+ mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
+ } else {
+ mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1)
+ }
}()
// todo: get this line logged with traceauth. the plaintext password is included on the command line, which we've already read (before dispatching to this function).
// Request syntax: ../rfc/9051:6667 ../rfc/3501:4804
p.xspace()
- userid := p.xastring()
+ username := p.xastring()
p.xspace()
password := p.xastring()
p.xempty()
@@ -1847,21 +2074,55 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) {
}
}()
- acc, err := store.OpenEmailAuth(c.log, userid, password)
+ account, err := store.OpenEmailAuth(c.log, username, password)
if err != nil {
authResult = "badcreds"
var code string
if errors.Is(err, store.ErrUnknownCredentials) {
code = "AUTHENTICATIONFAILED"
- c.log.Info("failed authentication attempt", slog.String("username", userid), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
}
xusercodeErrorf(code, "login failed")
}
- c.account = acc
- c.username = userid
+ defer func() {
+ if account != nil {
+ err := account.Close()
+ c.log.Check(err, "close account")
+ }
+ }()
+
+ // We may already have TLS credentials. They won't have been enabled, or we could
+ // get here due to the state machine that doesn't allow authentication while being
+ // authenticated. But allow another SASL authentication, but it has to be for the
+ // same account. It can be for a different username (email address) of the account.
+ if c.account != nil {
+ if account != c.account {
+ c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection",
+ slog.String("saslmechanism", "login"),
+ slog.String("saslaccount", account.Name),
+ slog.String("tlsaccount", c.account.Name),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ )
+ xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account")
+ } else if username != c.username {
+ c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username",
+ slog.String("saslmechanism", "login"),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ slog.String("account", c.account.Name),
+ )
+ }
+ } else {
+ c.account = account
+ account = nil // Prevent cleanup.
+ }
+ c.username = username
+ if c.comm == nil {
+ c.comm = store.RegisterComm(c.account)
+ }
c.authFailed = 0
c.setSlow(false)
- c.comm = store.RegisterComm(acc)
c.state = stateAuthenticated
authResult = "ok"
c.writeresultf("%s OK [CAPABILITY %s] login done", tag, c.capabilities())
diff --git a/imapserver/server_test.go b/imapserver/server_test.go
index a0564b228b..c7f3783cb7 100644
--- a/imapserver/server_test.go
+++ b/imapserver/server_test.go
@@ -162,6 +162,7 @@ type testconn struct {
done chan struct{}
serverConn net.Conn
account *store.Account
+ switchStop func()
// Result of last command.
lastUntagged []imapclient.Untagged
@@ -315,6 +316,9 @@ func (tc *testconn) close() {
tc.client.Close()
tc.serverConn.Close()
tc.waitDone()
+ if tc.switchStop != nil {
+ tc.switchStop()
+ }
}
func xparseNumSet(s string) imapclient.NumSet {
@@ -338,15 +342,23 @@ func startNoSwitchboard(t *testing.T) *testconn {
const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
const password1 = "tést " // PRECIS normalized, with NFC.
-func startArgs(t *testing.T, first, isTLS, allowLoginWithoutTLS, setPassword bool, accname string) *testconn {
+func startArgs(t *testing.T, first, immediateTLS bool, allowLoginWithoutTLS, setPassword bool, accname string) *testconn {
+ return startArgsMore(t, first, immediateTLS, nil, nil, allowLoginWithoutTLS, false, setPassword, accname, nil)
+}
+
+// todo: the parameters and usage are too much now. change to scheme similar to smtpserver, with params in a struct, and a separate method for init and making a connection.
+func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientConfig *tls.Config, allowLoginWithoutTLS, noCloseSwitchboard, setPassword bool, accname string, afterInit func() error) *testconn {
limitersInit() // Reset rate limiters.
- if first {
- os.RemoveAll("../testdata/imap/data")
- }
mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/imap/mox.conf")
mox.MustLoadConfig(true, false)
+ if first {
+ store.Close() // May not be open, we ignore error.
+ os.RemoveAll("../testdata/imap/data")
+ err := store.Init(ctxbg)
+ tcheck(t, err, "store init")
+ }
acc, err := store.OpenAccount(pkglog, accname)
tcheck(t, err, "open account")
if setPassword {
@@ -358,33 +370,55 @@ func startArgs(t *testing.T, first, isTLS, allowLoginWithoutTLS, setPassword boo
switchStop = store.Switchboard()
}
+ if afterInit != nil {
+ err := afterInit()
+ tcheck(t, err, "after init")
+ }
+
serverConn, clientConn := net.Pipe()
- tlsConfig := &tls.Config{
- Certificates: []tls.Certificate{fakeCert(t)},
+ if serverConfig == nil {
+ serverConfig = &tls.Config{
+ Certificates: []tls.Certificate{fakeCert(t, false)},
+ }
}
- if isTLS {
- serverConn = tls.Server(serverConn, tlsConfig)
- clientConn = tls.Client(clientConn, &tls.Config{InsecureSkipVerify: true})
+ if immediateTLS {
+ if clientConfig == nil {
+ clientConfig = &tls.Config{InsecureSkipVerify: true}
+ }
+ clientConn = tls.Client(clientConn, clientConfig)
}
done := make(chan struct{})
connCounter++
cid := connCounter
go func() {
- serve("test", cid, tlsConfig, serverConn, isTLS, allowLoginWithoutTLS)
- switchStop()
+ serve("test", cid, serverConfig, serverConn, immediateTLS, allowLoginWithoutTLS)
+ if !noCloseSwitchboard {
+ switchStop()
+ }
close(done)
}()
client, err := imapclient.New(clientConn, true)
tcheck(t, err, "new client")
- return &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn, account: acc}
+ tc := &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn, account: acc}
+ if first && noCloseSwitchboard {
+ tc.switchStop = switchStop
+ }
+ return tc
}
-func fakeCert(t *testing.T) tls.Certificate {
- privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
+func fakeCert(t *testing.T, randomkey bool) tls.Certificate {
+ seed := make([]byte, ed25519.SeedSize)
+ if randomkey {
+ cryptorand.Read(seed)
+ }
+ privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
template := &x509.Certificate{
SerialNumber: big.NewInt(1), // Required field...
+ // Valid period is needed to get session resumption enabled.
+ NotBefore: time.Now().Add(-time.Minute),
+ NotAfter: time.Now().Add(time.Hour),
}
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
if err != nil {
diff --git a/junk.go b/junk.go
index 0aabc3dd11..aa00bfee9a 100644
--- a/junk.go
+++ b/junk.go
@@ -214,12 +214,12 @@ messages are shuffled, with optional random seed.`
hamFiles := listDir(hamDir)
spamFiles := listDir(spamDir)
- var rand *mathrand.Rand
+ var seed int64
if a.seed {
- rand = mathrand.New(mathrand.NewSource(time.Now().UnixMilli()))
- } else {
- rand = mathrand.New(mathrand.NewSource(0))
+ seed = time.Now().UnixMilli()
}
+ // Still at math/rand (v1 instead of v2) for potential comparison to earlier test results.
+ rand := mathrand.New(mathrand.NewSource(seed))
shuffle := func(l []string) {
count := len(l)
diff --git a/localserve.go b/localserve.go
index d10ca5ce25..48f65f1cd1 100644
--- a/localserve.go
+++ b/localserve.go
@@ -25,6 +25,7 @@ import (
"github.com/mjl-/sconf"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns"
@@ -164,7 +165,7 @@ during those commands instead of during "data".
loadLoglevel(log, fallbackLevel)
- golog.Printf("mox, version %s, %s %s/%s", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
+ golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH)
golog.Print("")
golog.Printf("the default user is mox@localhost, with password moxmoxmox")
golog.Printf("the default admin password is moxadmin")
@@ -421,7 +422,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
},
}
- dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
+ dkimKeyBuf, err := admin.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
xcheck(err, "making dkim key")
dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
diff --git a/main.go b/main.go
index 8fbca8ab78..ff19afa52f 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,7 @@ import (
"github.com/mjl-/sconf"
"github.com/mjl-/sherpa"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dane"
"github.com/mjl-/mox/dkim"
@@ -149,6 +150,11 @@ var commands = []struct {
{"config address rm", cmdConfigAddressRemove},
{"config domain add", cmdConfigDomainAdd},
{"config domain rm", cmdConfigDomainRemove},
+ {"config tlspubkey list", cmdConfigTlspubkeyList},
+ {"config tlspubkey get", cmdConfigTlspubkeyGet},
+ {"config tlspubkey add", cmdConfigTlspubkeyAdd},
+ {"config tlspubkey rm", cmdConfigTlspubkeyRemove},
+ {"config tlspubkey gen", cmdConfigTlspubkeyGen},
{"config alias list", cmdConfigAliasList},
{"config alias print", cmdConfigAliasPrint},
{"config alias add", cmdConfigAliasAdd},
@@ -570,7 +576,7 @@ configured over otherwise secured connections, like a VPN.
}
func printClientConfig(d dns.Domain) {
- cc, err := mox.ClientConfigsDomain(d)
+ cc, err := admin.ClientConfigsDomain(d)
xcheckf(err, "getting client config")
fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note")
for _, e := range cc.Entries {
@@ -920,6 +926,200 @@ func ctlcmdConfigAccountRemove(ctl *ctl, account string) {
fmt.Println("account removed")
}
+func cmdConfigTlspubkeyList(c *cmd) {
+ c.params = "[account]"
+ c.help = `List TLS public keys for TLS client certificate authentication.
+
+If account is absent, the TLS public keys for all accounts are listed.
+`
+ args := c.Parse()
+ var accountOpt string
+ if len(args) == 1 {
+ accountOpt = args[0]
+ } else if len(args) > 1 {
+ c.Usage()
+ }
+
+ mustLoadConfig()
+ ctlcmdConfigTlspubkeyList(xctl(), accountOpt)
+}
+
+func ctlcmdConfigTlspubkeyList(ctl *ctl, accountOpt string) {
+ ctl.xwrite("tlspubkeylist")
+ ctl.xwrite(accountOpt)
+ ctl.xreadok()
+ ctl.xstreamto(os.Stdout)
+}
+
+func cmdConfigTlspubkeyGet(c *cmd) {
+ c.params = "fingerprint"
+ c.help = `Get a TLS public key for a fingerprint.
+
+Prints the type, name, account and address for the key, and the certificate in
+PEM format.
+`
+ args := c.Parse()
+ if len(args) != 1 {
+ c.Usage()
+ }
+
+ mustLoadConfig()
+ ctlcmdConfigTlspubkeyGet(xctl(), args[0])
+}
+
+func ctlcmdConfigTlspubkeyGet(ctl *ctl, fingerprint string) {
+ ctl.xwrite("tlspubkeyget")
+ ctl.xwrite(fingerprint)
+ ctl.xreadok()
+ typ := ctl.xread()
+ name := ctl.xread()
+ account := ctl.xread()
+ address := ctl.xread()
+ noimappreauth := ctl.xread()
+ var b bytes.Buffer
+ ctl.xstreamto(&b)
+ buf := b.Bytes()
+ var block *pem.Block
+ if len(buf) != 0 {
+ block = &pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: buf,
+ }
+ }
+
+ fmt.Printf("type: %s\nname: %s\naccount: %s\naddress: %s\nno imap preauth: %s\n", typ, name, account, address, noimappreauth)
+ if block != nil {
+ fmt.Printf("certificate:\n\n")
+ pem.Encode(os.Stdout, block)
+ }
+}
+
+func cmdConfigTlspubkeyAdd(c *cmd) {
+ c.params = "address [name] < cert.pem"
+ c.help = `Add a TLS public key to the account of the given address.
+
+The public key is read from the certificate.
+
+The optional name is a human-readable descriptive name of the key. If absent,
+the CommonName from the certificate is used.
+`
+ var noimappreauth bool
+ c.flag.BoolVar(&noimappreauth, "no-imap-preauth", false, "Don't automatically switch new IMAP connections authenticated with this key to \"authenticated\" state after the TLS handshake. For working around clients that ignore the untagged IMAP PREAUTH response and try to authenticate while already authenticated.")
+ args := c.Parse()
+ var address, name string
+ if len(args) == 1 {
+ address = args[0]
+ } else if len(args) == 2 {
+ address, name = args[0], args[1]
+ } else {
+ c.Usage()
+ }
+
+ buf, err := io.ReadAll(os.Stdin)
+ xcheckf(err, "reading from stdin")
+ block, _ := pem.Decode(buf)
+ if block == nil {
+ err = errors.New("no pem block found")
+ } else if block.Type != "CERTIFICATE" {
+ err = fmt.Errorf("unexpected type %q, expected CERTIFICATE", block.Type)
+ }
+ xcheckf(err, "parsing pem")
+
+ mustLoadConfig()
+ ctlcmdConfigTlspubkeyAdd(xctl(), address, name, noimappreauth, block.Bytes)
+}
+
+func ctlcmdConfigTlspubkeyAdd(ctl *ctl, address, name string, noimappreauth bool, certDER []byte) {
+ ctl.xwrite("tlspubkeyadd")
+ ctl.xwrite(address)
+ ctl.xwrite(name)
+ ctl.xwrite(fmt.Sprintf("%v", noimappreauth))
+ ctl.xstreamfrom(bytes.NewReader(certDER))
+ ctl.xreadok()
+}
+
+func cmdConfigTlspubkeyRemove(c *cmd) {
+ c.params = "fingerprint"
+ c.help = `Remove TLS public key for fingerprint.`
+ args := c.Parse()
+ if len(args) != 1 {
+ c.Usage()
+ }
+
+ mustLoadConfig()
+ ctlcmdConfigTlspubkeyRemove(xctl(), args[0])
+}
+
+func ctlcmdConfigTlspubkeyRemove(ctl *ctl, fingerprint string) {
+ ctl.xwrite("tlspubkeyrm")
+ ctl.xwrite(fingerprint)
+ ctl.xreadok()
+}
+
+func cmdConfigTlspubkeyGen(c *cmd) {
+ c.params = "stem"
+ c.help = `Generate an ed25519 private key and minimal certificate for use a TLS public key and write to files starting with stem.
+
+The private key is written to $stem.$timestamp.ed25519privatekey.pkcs8.pem.
+The certificate is written to $stem.$timestamp.certificate.pem.
+The private key and certificate are also written to
+$stem.$timestamp.ed25519privatekey-certificate.pem.
+
+The certificate can be added to an account with "mox config account tlspubkey add".
+
+The combined file can be used with "mox sendmail".
+
+The private key is also written to standard error in raw-url-base64-encoded
+form, also for use with "mox sendmail". The fingerprint is written to standard
+error too, for reference.
+`
+ args := c.Parse()
+ if len(args) != 1 {
+ c.Usage()
+ }
+
+ stem := args[0]
+ timestamp := time.Now().Format("200601021504")
+ prefix := stem + "." + timestamp
+
+ seed := make([]byte, ed25519.SeedSize)
+ if _, err := cryptorand.Read(seed); err != nil {
+ panic(err)
+ }
+ privKey := ed25519.NewKeyFromSeed(seed)
+ privKeyBuf, err := x509.MarshalPKCS8PrivateKey(privKey)
+ xcheckf(err, "marshal private key as pkcs8")
+ var b bytes.Buffer
+ err = pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privKeyBuf})
+ xcheckf(err, "marshal pkcs8 private key to pem")
+ privKeyBufPEM := b.Bytes()
+
+ certBuf, tlsCert := xminimalCert(privKey)
+ b = bytes.Buffer{}
+ err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf})
+ xcheckf(err, "marshal certificate to pem")
+ certBufPEM := b.Bytes()
+
+ xwriteFile := func(p string, data []byte, what string) {
+ log.Printf("writing %s", p)
+ err = os.WriteFile(p, data, 0600)
+ xcheckf(err, "writing %s file: %v", what, err)
+ }
+
+ xwriteFile(prefix+".ed25519privatekey.pkcs8.pem", privKeyBufPEM, "private key")
+ xwriteFile(prefix+".certificate.pem", certBufPEM, "certificate")
+ combinedPEM := append(append([]byte{}, privKeyBufPEM...), certBufPEM...)
+ xwriteFile(prefix+".ed25519privatekey-certificate.pem", combinedPEM, "combined private key and certificate")
+
+ shabuf := sha256.Sum256(tlsCert.Leaf.RawSubjectPublicKeyInfo)
+
+ _, err = fmt.Fprintf(os.Stderr, "ed25519 private key as raw-url-base64: %s\ned25519 public key fingerprint: %s\n",
+ base64.RawURLEncoding.EncodeToString(seed),
+ base64.RawURLEncoding.EncodeToString(shabuf[:]),
+ )
+ xcheckf(err, "write private key and public key fingerprint")
+}
+
func cmdConfigAddressAdd(c *cmd) {
c.params = "address account"
c.help = `Adds an address to an account and reloads the configuration.
@@ -1006,7 +1206,7 @@ configured.
}
}
- records, err := mox.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
+ records, err := admin.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
xcheckf(err, "records")
fmt.Print(strings.Join(records, "\n") + "\n")
}
@@ -1539,7 +1739,7 @@ with DKIM, by mox.
c.Usage()
}
- buf, err := mox.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
+ buf, err := admin.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
xcheckf(err, "making rsa private key")
_, err = os.Stdout.Write(buf)
xcheckf(err, "writing rsa private key")
@@ -2077,7 +2277,7 @@ so it is recommended to sign messages with both RSA and ed25519 keys.
c.Usage()
}
- buf, err := mox.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
+ buf, err := admin.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
xcheckf(err, "making dkim ed25519 key")
_, err = os.Stdout.Write(buf)
xcheckf(err, "writing dkim ed25519 key")
@@ -2786,7 +2986,7 @@ printed.
}
mustLoadConfig()
- current, lastknown, _, err := mox.LastKnown()
+ current, lastknown, _, err := store.LastKnown()
if err != nil {
log.Printf("getting last known version: %s", err)
} else {
@@ -2845,7 +3045,7 @@ func cmdVersion(c *cmd) {
c.Usage()
}
fmt.Println(moxvar.Version)
- fmt.Printf("%s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
+ fmt.Printf("%s/%s\n", runtime.GOOS, runtime.GOARCH)
}
func cmdWebapi(c *cmd) {
@@ -2856,7 +3056,7 @@ func cmdWebapi(c *cmd) {
c.Usage()
}
- t := reflect.TypeOf((*webapi.Methods)(nil)).Elem()
+ t := reflect.TypeFor[webapi.Methods]()
methods := map[string]reflect.Type{}
var ml []string
for i := 0; i < t.NumMethod(); i++ {
diff --git a/message/part.go b/message/part.go
index 4d158d737a..bf1b8b7e38 100644
--- a/message/part.go
+++ b/message/part.go
@@ -598,6 +598,70 @@ func (p *Part) IsDSN() bool {
(p.Parts[1].MediaSubType == "DELIVERY-STATUS" || p.Parts[1].MediaSubType == "GLOBAL-DELIVERY-STATUS")
}
+var ErrParamEncoding = errors.New("bad header parameter encoding")
+
+// DispositionFilename tries to parse the disposition header and the "filename"
+// parameter. If the filename parameter is absent or can't be parsed, the "name"
+// parameter from the Content-Type header is used for the filename. The returned
+// filename is decoded according to RFC 2231 or RFC 2047. This is a best-effort
+// attempt to find a filename for a part. If no Content-Disposition header, or
+// filename was found, empty values without error are returned.
+//
+// If the returned error is an ErrParamEncoding, it can be treated as a diagnostic
+// and a filename may still be returned.
+func (p *Part) DispositionFilename() (disposition string, filename string, err error) {
+ h, err := p.Header()
+ if err != nil {
+ return "", "", fmt.Errorf("parsing header: %v", err)
+ }
+ var disp string
+ var params map[string]string
+ cd := h.Get("Content-Disposition")
+ if cd != "" {
+ disp, params, err = mime.ParseMediaType(cd)
+ }
+ if err != nil {
+ return "", "", fmt.Errorf("%w: parsing disposition header: %v", ErrParamEncoding, err)
+ }
+ filename, err = tryDecodeParam(params["filename"])
+ if filename == "" {
+ s, err2 := tryDecodeParam(p.ContentTypeParams["name"])
+ filename = s
+ if err == nil {
+ err = err2
+ }
+ }
+ return disp, filename, err
+}
+
+// Attempt q/b-word-decode name, coming from Content-Type "name" field or
+// Content-Disposition "filename" field.
+//
+// RFC 2231 specifies an encoding for non-ascii values in mime header parameters. But
+// it appears common practice to instead just q/b-word encode the values.
+// Thunderbird and gmail.com do this for the Content-Type "name" parameter.
+// gmail.com also does that for the Content-Disposition "filename" parameter, where
+// Thunderbird uses the RFC 2231-defined encoding. Go's mime.ParseMediaType parses
+// the mechanism specified in RFC 2231 only. The value for "name" we get here would
+// already be decoded properly for standards-compliant headers, like
+// "filename*0*=UTF-8”%...; filename*1*=%.... We'll look for Q/B-word encoding
+// markers ("=?"-prefix or "?="-suffix) and try to decode if present. This would
+// only cause trouble for filenames having this prefix/suffix.
+func tryDecodeParam(name string) (string, error) {
+ if name == "" || !strings.HasPrefix(name, "=?") && !strings.HasSuffix(name, "?=") {
+ return name, nil
+ }
+ // todo: find where this is allowed. it seems quite common. perhaps we should remove the pedantic check?
+ if Pedantic {
+ return name, fmt.Errorf("%w: attachment contains rfc2047 q/b-word-encoded mime parameter instead of rfc2231-encoded", ErrParamEncoding)
+ }
+ s, err := wordDecoder.DecodeHeader(name)
+ if err != nil {
+ return name, fmt.Errorf("%w: q/b-word decoding mime parameter: %v", ErrParamEncoding, err)
+ }
+ return s, nil
+}
+
// Reader returns a reader for the decoded body content.
func (p *Part) Reader() io.Reader {
return p.bodyReader(p.RawReader())
diff --git a/metrics/auth.go b/metrics/auth.go
index 3014a9d0b5..97f22f5975 100644
--- a/metrics/auth.go
+++ b/metrics/auth.go
@@ -14,7 +14,7 @@ var (
},
[]string{
"kind", // submission, imap, webmail, webapi, webaccount, webadmin (formerly httpaccount, httpadmin)
- "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic.
+ "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic, tlsclientauth.
// todo: we currently only use badcreds, but known baduser can be helpful
"result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted
},
diff --git a/mlog/log.go b/mlog/log.go
index d041c98ae0..122599a0a1 100644
--- a/mlog/log.go
+++ b/mlog/log.go
@@ -25,10 +25,34 @@ import (
"strings"
"sync/atomic"
"time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
)
var noctx = context.Background()
+var (
+ metricLogInfo = promauto.NewCounter(
+ prometheus.CounterOpts{
+ Name: "mox_logging_level_info_total",
+ Help: "Total number of logging events at level info.",
+ },
+ )
+ metricLogWarn = promauto.NewCounter(
+ prometheus.CounterOpts{
+ Name: "mox_logging_level_warn_total",
+ Help: "Total number of logging events at level warn.",
+ },
+ )
+ metricLogError = promauto.NewCounter(
+ prometheus.CounterOpts{
+ Name: "mox_logging_level_error_total",
+ Help: "Total number of logging events at level error.",
+ },
+ )
+)
+
// Logfmt enabled output in logfmt, instead of output more suitable for
// command-line tools. Must be set early in a program lifecycle.
var Logfmt bool
@@ -325,6 +349,15 @@ func (h *handler) Handle(ctx context.Context, r slog.Record) error {
if !ok {
return nil
}
+ if r.Level >= LevelInfo {
+ if r.Level == LevelInfo {
+ metricLogInfo.Inc()
+ } else if r.Level <= LevelWarn {
+ metricLogWarn.Inc()
+ } else if r.Level <= LevelError {
+ metricLogError.Inc()
+ }
+ }
if hideData, hideAuth := traceLevel(l, r.Level); hideData {
r.Message = "..."
} else if hideAuth {
diff --git a/mox-/config.go b/mox-/config.go
index a238f0786b..826aa16066 100644
--- a/mox-/config.go
+++ b/mox-/config.go
@@ -86,11 +86,13 @@ type Config struct {
Dynamic config.Dynamic // Can only be accessed directly by tests. Use methods on Config for locked access.
dynamicMtime time.Time
DynamicLastCheck time.Time // For use by quickstart only to skip checks.
+
// From canonical full address (localpart@domain, lower-cased when
// case-insensitive, stripped of catchall separator) to account and address.
- // Domains are IDNA names in utf8.
- accountDestinations map[string]AccountDestination
- // Like accountDestinations, but for aliases.
+ // Domains are IDNA names in utf8. Dynamic config lock must be held when accessing.
+ AccountDestinationsLocked map[string]AccountDestination
+
+ // Like AccountDestinationsLocked, but for aliases.
aliases map[string]config.Alias
}
@@ -142,9 +144,11 @@ func (c *Config) LogLevels() map[string]slog.Level {
return c.copyLogLevels()
}
-func (c *Config) withDynamicLock(fn func()) {
+// DynamicLockUnlock locks the dynamic config, will try updating the latest state
+// from disk, and return an unlock function. Should be called as "defer
+// Conf.DynamicLockUnlock()()".
+func (c *Config) DynamicLockUnlock() func() {
c.dynamicMutex.Lock()
- defer c.dynamicMutex.Unlock()
now := time.Now()
if now.Sub(c.DynamicLastCheck) > time.Second {
c.DynamicLastCheck = now
@@ -159,6 +163,11 @@ func (c *Config) withDynamicLock(fn func()) {
}
}
}
+ return c.dynamicMutex.Unlock
+}
+
+func (c *Config) withDynamicLock(fn func()) {
+ defer c.DynamicLockUnlock()()
fn()
}
@@ -170,7 +179,7 @@ func (c *Config) loadDynamic() []error {
}
c.Dynamic = d
c.dynamicMtime = mtime
- c.accountDestinations = accDests
+ c.AccountDestinationsLocked = accDests
c.aliases = aliases
c.allowACMEHosts(pkglog, true)
return nil
@@ -213,7 +222,7 @@ func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]c
m := map[string]string{}
aliases := map[string]config.Alias{}
c.withDynamicLock(func() {
- for addr, ad := range c.accountDestinations {
+ for addr, ad := range c.AccountDestinationsLocked {
if strings.HasSuffix(addr, suffix) {
if ad.Catchall {
m[""] = ad.Account
@@ -247,7 +256,7 @@ func (c *Config) Account(name string) (acc config.Account, ok bool) {
func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) {
c.withDynamicLock(func() {
- accDest, ok = c.accountDestinations[addr]
+ accDest, ok = c.AccountDestinationsLocked[addr]
if !ok {
var a config.Alias
a, ok = c.aliases[addr]
@@ -345,9 +354,13 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
// todo future: write config parsing & writing code that can read a config and remembers the exact tokens including newlines and comments, and can write back a modified file. the goal is to be able to write a config file automatically (after changing fields through the ui), but not loose comments and whitespace, to still get useful diffs for storing the config in a version control system.
-// must be called with lock held.
+// WriteDynamicLocked prepares an updated internal state for the new dynamic
+// config, then writes it to disk and activates it.
+//
// Returns ErrConfig if the configuration is not valid.
-func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
+//
+// Must be called with config lock held.
+func WriteDynamicLocked(ctx context.Context, log mlog.Log, c config.Dynamic) error {
accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c)
if len(errs) > 0 {
errstrs := make([]string, len(errs))
@@ -399,7 +412,7 @@ func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
Conf.dynamicMtime = fi.ModTime()
Conf.DynamicLastCheck = time.Now()
Conf.Dynamic = c
- Conf.accountDestinations = accDests
+ Conf.AccountDestinationsLocked = accDests
Conf.aliases = aliases
Conf.allowACMEHosts(log, true)
@@ -440,7 +453,7 @@ func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEH
// SetConfig sets a new config. Not to be used during normal operation.
func SetConfig(c *Config) {
// Cannot just assign *c to Conf, it would copy the mutex.
- Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations, c.aliases}
+ Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.AccountDestinationsLocked, c.aliases}
// If we have non-standard CA roots, use them for all HTTPS requests.
if Conf.Static.TLS.CertPool != nil {
@@ -491,7 +504,7 @@ func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadT
}
pp := filepath.Join(filepath.Dir(p), "domains.conf")
- c.Dynamic, c.dynamicMtime, c.accountDestinations, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
+ c.Dynamic, c.dynamicMtime, c.AccountDestinationsLocked, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
if !checkOnly {
c.allowACMEHosts(log, checkACMEHosts)
@@ -1920,8 +1933,7 @@ func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error {
certs = append(certs, cert)
}
ctls.Config = &tls.Config{
- Certificates: certs,
- SessionTicketsDisabled: true,
+ Certificates: certs,
}
ctls.ConfigFallback = ctls.Config
return nil
diff --git a/mox-/dir.go b/mox-/dir.go
index 792b673246..7ea3e52774 100644
--- a/mox-/dir.go
+++ b/mox-/dir.go
@@ -5,11 +5,18 @@ import (
)
// ConfigDirPath returns the path to "f". Either f itself when absolute, or
-// interpreted relative to the directory of the current config file.
+// interpreted relative to the directory of the static configuration file
+// (mox.conf).
func ConfigDirPath(f string) string {
return configDirPath(ConfigStaticPath, f)
}
+// Like ConfigDirPath, but relative paths are interpreted relative to the directory
+// of the dynamic configuration file (domains.conf).
+func ConfigDynamicDirPath(f string) string {
+ return configDirPath(ConfigDynamicPath, f)
+}
+
// DataDirPath returns to the path to "f". Either f itself when absolute, or
// interpreted relative to the data directory from the currently active
// configuration.
diff --git a/mox-/ip.go b/mox-/ip.go
index 116322b3f7..c5d9f2810c 100644
--- a/mox-/ip.go
+++ b/mox-/ip.go
@@ -1,6 +1,9 @@
package mox
import (
+ "context"
+ "fmt"
+ "log/slog"
"net"
)
@@ -19,3 +22,109 @@ func Network(ip string) string {
}
return "tcp6"
}
+
+// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
+// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
+// transports.
+func DomainSPFIPs() (ips []net.IP) {
+ for _, l := range Conf.Static.Listeners {
+ if !l.SMTP.Enabled || l.IPsNATed {
+ continue
+ }
+ ipstrs := l.IPs
+ if len(l.NATIPs) > 0 {
+ ipstrs = l.NATIPs
+ }
+ for _, ipstr := range ipstrs {
+ ip := net.ParseIP(ipstr)
+ if ip.IsUnspecified() {
+ continue
+ }
+ ips = append(ips, ip)
+ }
+ }
+ for _, t := range Conf.Static.Transports {
+ if t.Socks != nil {
+ ips = append(ips, t.Socks.IPs...)
+ }
+ }
+ return ips
+}
+
+// IPs returns ip addresses we may be listening/receiving mail on or
+// connecting/sending from to the outside.
+func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
+ log := pkglog.WithContext(ctx)
+
+ // Try to gather all IPs we are listening on by going through the config.
+ // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
+ var ips []net.IP
+ var ipv4all, ipv6all bool
+ for _, l := range Conf.Static.Listeners {
+ // If NATed, we don't know our external IPs.
+ if l.IPsNATed {
+ return nil, nil
+ }
+ check := l.IPs
+ if len(l.NATIPs) > 0 {
+ check = l.NATIPs
+ }
+ for _, s := range check {
+ ip := net.ParseIP(s)
+ if ip.IsUnspecified() {
+ if ip.To4() != nil {
+ ipv4all = true
+ } else {
+ ipv6all = true
+ }
+ continue
+ }
+ ips = append(ips, ip)
+ }
+ }
+
+ // We'll list the IPs on the interfaces. How useful is this? There is a good chance
+ // we're listening on all addresses because of a load balancer/firewall.
+ if ipv4all || ipv6all {
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return nil, fmt.Errorf("listing network interfaces: %v", err)
+ }
+ for _, iface := range ifaces {
+ if iface.Flags&net.FlagUp == 0 {
+ continue
+ }
+ addrs, err := iface.Addrs()
+ if err != nil {
+ return nil, fmt.Errorf("listing addresses for network interface: %v", err)
+ }
+ if len(addrs) == 0 {
+ continue
+ }
+
+ for _, addr := range addrs {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err != nil {
+ log.Errorx("bad interface addr", err, slog.Any("address", addr))
+ continue
+ }
+ v4 := ip.To4() != nil
+ if ipv4all && v4 || ipv6all && !v4 {
+ ips = append(ips, ip)
+ }
+ }
+ }
+ }
+
+ if receiveOnly {
+ return ips, nil
+ }
+
+ for _, t := range Conf.Static.Transports {
+ if t.Socks != nil {
+ ips = append(ips, t.Socks.IPs...)
+ }
+ }
+
+ return ips, nil
+}
diff --git a/mox-/lifecycle.go b/mox-/lifecycle.go
index 752cc189ae..ce601a557f 100644
--- a/mox-/lifecycle.go
+++ b/mox-/lifecycle.go
@@ -142,7 +142,7 @@ func OpenPrivileged(path string) (*os.File, error) {
// Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic
// processes should check this before starting a new operation. If this context is
-// canaceled, the operation should not be started, and new connections/commands should
+// canceled, the operation should not be started, and new connections/commands should
// receive a message that the service is currently not available.
var Shutdown context.Context
var ShutdownCancel func()
diff --git a/mox-/lookup.go b/mox-/lookup.go
index 1fae8d5236..61866d93cc 100644
--- a/mox-/lookup.go
+++ b/mox-/lookup.go
@@ -14,7 +14,7 @@ var (
ErrAddressNotFound = errors.New("address not found")
)
-// FindAccount looks up the account for localpart and domain.
+// LookupAddress looks up the account for localpart and domain.
//
// Can return ErrDomainNotFound and ErrAddressNotFound.
func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, allowAlias bool) (accountName string, alias *config.Alias, canonicalAddress string, dest config.Destination, rerr error) {
diff --git a/mox-/rand.go b/mox-/rand.go
index 3aac6d74d4..d37153ba16 100644
--- a/mox-/rand.go
+++ b/mox-/rand.go
@@ -4,19 +4,23 @@ import (
cryptorand "crypto/rand"
"encoding/binary"
"fmt"
- mathrand "math/rand"
+ mathrand2 "math/rand/v2"
"sync"
)
type rand struct {
- rand *mathrand.Rand
+ rand *mathrand2.Rand
sync.Mutex
}
// NewPseudoRand returns a new PRNG seeded with random bytes from crypto/rand. Its
// functions can be called concurrently.
func NewPseudoRand() *rand {
- return &rand{rand: mathrand.New(mathrand.NewSource(CryptoRandInt()))}
+ var seed [32]byte
+ if _, err := cryptorand.Read(seed[:]); err != nil {
+ panic(err)
+ }
+ return &rand{rand: mathrand2.New(mathrand2.NewChaCha8(seed))}
}
func (r *rand) Float64() float64 {
@@ -25,16 +29,10 @@ func (r *rand) Float64() float64 {
return r.rand.Float64()
}
-func (r *rand) Intn(n int) int {
- r.Lock()
- defer r.Unlock()
- return r.rand.Intn(n)
-}
-
-func (r *rand) Read(buf []byte) (int, error) {
+func (r *rand) IntN(n int) int {
r.Lock()
defer r.Unlock()
- return r.rand.Read(buf)
+ return r.rand.IntN(n)
}
// CryptoRandInt returns a cryptographically random number.
diff --git a/mox-/sleep.go b/mox-/sleep.go
index af56bc5a35..8bfe322569 100644
--- a/mox-/sleep.go
+++ b/mox-/sleep.go
@@ -9,11 +9,13 @@ import (
//
// Used for a few places where sleep is used to push back on clients, but where
// shutting down should abort the sleep.
-func Sleep(ctx context.Context, d time.Duration) {
+func Sleep(ctx context.Context, d time.Duration) (ctxDone bool) {
t := time.NewTicker(d)
defer t.Stop()
select {
case <-t.C:
+ return false
case <-ctx.Done():
+ return true
}
}
diff --git a/mox-/tlsalert.go b/mox-/tlsalert.go
new file mode 100644
index 0000000000..eb7f4d092c
--- /dev/null
+++ b/mox-/tlsalert.go
@@ -0,0 +1,23 @@
+package mox
+
+import (
+ "errors"
+ "net"
+ "reflect"
+)
+
+func AsTLSAlert(err error) (alert uint8, ok bool) {
+ // If the remote client aborts the connection, it can send an alert indicating why.
+ // crypto/tls gives us a net.OpError with "Op" set to "remote error", an an Err
+ // with the unexported type "alert", a uint8. So we try to read it.
+
+ var opErr *net.OpError
+ if !errors.As(err, &opErr) || opErr.Op != "remote error" || opErr.Err == nil {
+ return
+ }
+ v := reflect.ValueOf(opErr.Err)
+ if v.Kind() != reflect.Uint8 || v.Type().Name() != "alert" {
+ return
+ }
+ return uint8(v.Uint()), true
+}
diff --git a/mox-/tlssessionticket.go b/mox-/tlssessionticket.go
new file mode 100644
index 0000000000..d2574b3d1f
--- /dev/null
+++ b/mox-/tlssessionticket.go
@@ -0,0 +1,51 @@
+package mox
+
+import (
+ "context"
+ cryptorand "crypto/rand"
+ "crypto/tls"
+ "time"
+
+ "github.com/mjl-/mox/mlog"
+)
+
+// StartTLSSessionTicketKeyRefresher sets session keys on the TLS config, and
+// rotates them periodically.
+//
+// Useful for TLS configs that are being cloned for each connection. The
+// automatically managed keys would happen in the cloned config, and not make
+// it back to the base config.
+func StartTLSSessionTicketKeyRefresher(ctx context.Context, log mlog.Log, c *tls.Config) {
+ var keys [][32]byte
+ first := make(chan struct{})
+
+ // Similar to crypto/tls, we rotate keys once a day. Previous keys stay valid for 7
+ // days. We currently only store ticket keys in memory, so a restart invalidates
+ // previous session tickets. We could store them in the future.
+ go func() {
+ for {
+ var nk [32]byte
+ if _, err := cryptorand.Read(nk[:]); err != nil {
+ panic(err)
+ }
+ if len(keys) > 7 {
+ keys = keys[:7]
+ }
+ keys = append([][32]byte{nk}, keys...)
+ c.SetSessionTicketKeys(keys)
+
+ if first != nil {
+ first <- struct{}{}
+ first = nil
+ }
+
+ ctxDone := Sleep(ctx, 24*time.Hour)
+ if ctxDone {
+ break
+ }
+ log.Info("rotating tls session keys")
+ }
+ }()
+
+ <-first
+}
diff --git a/mox-/txt.go b/mox-/txt.go
new file mode 100644
index 0000000000..47bc173b51
--- /dev/null
+++ b/mox-/txt.go
@@ -0,0 +1,24 @@
+package mox
+
+// TXTStrings returns a TXT record value as one or more quoted strings, each max
+// 100 characters. In case of multiple strings, a multi-line record is returned.
+func TXTStrings(s string) string {
+ if len(s) <= 100 {
+ return `"` + s + `"`
+ }
+
+ r := "(\n"
+ for len(s) > 0 {
+ n := len(s)
+ if n > 100 {
+ n = 100
+ }
+ if r != "" {
+ r += " "
+ }
+ r += "\t\t\"" + s[:n] + "\"\n"
+ s = s[n:]
+ }
+ r += "\t)"
+ return r
+}
diff --git a/mox-/webappfile.go b/mox-/webappfile.go
index f6bebc1e73..ff879401c5 100644
--- a/mox-/webappfile.go
+++ b/mox-/webappfile.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
"log/slog"
"net/http"
"os"
@@ -25,6 +26,7 @@ import (
type WebappFile struct {
HTML, JS []byte // Embedded html/js data.
HTMLPath, JSPath string // Paths to load html/js from during development.
+ CustomStem string // For trying to read css/js customizations from $configdir/$stem.{css,js}.
sync.Mutex
combined []byte
@@ -107,28 +109,82 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri
}
}
+ // Check mtime of css/js files.
+ var haveCustomCSS, haveCustomJS bool
+ checkCustomMtime := func(ext string, have *bool) bool {
+ path := ConfigDirPath(a.CustomStem + "." + ext)
+ if fi, err := os.Stat(path); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ a.serverError(log, w, err, "stat customization file")
+ return false
+ }
+ } else if mtm := fi.ModTime(); mtm.After(diskmtime) {
+ diskmtime = mtm
+ *have = true
+ }
+ return true
+ }
+ if !checkCustomMtime("css", &haveCustomCSS) || !checkCustomMtime("js", &haveCustomJS) {
+ return
+ }
+ // Detect removal of custom files.
+ if fi, err := os.Stat(ConfigDirPath(".")); err == nil && fi.ModTime().After(diskmtime) {
+ diskmtime = fi.ModTime()
+ }
+
+ a.Lock()
+ refreshdisk = refreshdisk || diskmtime.After(a.mtime)
+ a.Unlock()
+
gz := AcceptsGzip(r)
var out []byte
var mtime time.Time
var origSize int64
- func() {
+ ok := func() bool {
a.Lock()
defer a.Unlock()
if refreshdisk || a.combined == nil {
- script := []byte(``)
- index := bytes.Index(html, script)
- if index < 0 {
- a.serverError(log, w, errors.New("script not found"), "generating combined html")
- return
+ var customCSS, customJS []byte
+ var err error
+ if haveCustomCSS {
+ customCSS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".css"))
+ if err != nil {
+ a.serverError(log, w, err, "read custom css file")
+ return false
+ }
+ }
+ if haveCustomJS {
+ customJS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".js"))
+ if err != nil {
+ a.serverError(log, w, err, "read custom js file")
+ return false
+ }
+ }
+
+ cssp := []byte(`/* css placeholder */`)
+ cssi := bytes.Index(html, cssp)
+ if cssi < 0 {
+ a.serverError(log, w, errors.New("css placeholder not found"), "generating combined html")
+ return false
+ }
+ jsp := []byte(`/* js placeholder */`)
+ jsi := bytes.Index(html, jsp)
+ if jsi < 0 {
+ a.serverError(log, w, errors.New("js placeholder not found"), "generating combined html")
+ return false
}
var b bytes.Buffer
- b.Write(html[:index])
- fmt.Fprintf(&b, "")
- b.Write(html[index+len(script):])
+ b.Write(html[jsi+len(jsp):])
out = b.Bytes()
a.combined = out
if refreshdisk {
@@ -152,7 +208,7 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri
}
if err != nil {
a.serverError(log, w, err, "gzipping combined html")
- return
+ return false
}
a.combinedGzip = b.Bytes()
}
@@ -160,7 +216,11 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri
out = a.combinedGzip
}
mtime = a.mtime
+ return true
}()
+ if !ok {
+ return
+ }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out))
diff --git a/moxvar/version.go b/moxvar/version.go
index 0141b86e90..0b69cd9bb3 100644
--- a/moxvar/version.go
+++ b/moxvar/version.go
@@ -2,16 +2,24 @@
package moxvar
import (
+ "runtime"
"runtime/debug"
)
// Version is set at runtime based on the Go module used to build.
-var Version = "(devel)"
+var Version string
-// VersionBare does not add a "+modifications" or other suffix to the version.
-var VersionBare = "(devel)"
+// VersionBare does not add a "+modifications", goversion or other suffix to the version.
+var VersionBare string
func init() {
+ Version = "(devel)"
+ VersionBare = "(devel)"
+
+ defer func() {
+ Version += "-" + runtime.Version()
+ }()
+
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return
diff --git a/mtastsdb/refresh.go b/mtastsdb/refresh.go
index d4c795062f..8783aa93de 100644
--- a/mtastsdb/refresh.go
+++ b/mtastsdb/refresh.go
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
- mathrand "math/rand"
+ mathrand2 "math/rand/v2"
"runtime/debug"
"time"
@@ -71,12 +71,11 @@ func refresh1(ctx context.Context, log mlog.Log, resolver dns.Resolver, sleep fu
}
// Randomize list.
- rand := mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
for i := range prs {
if i == 0 {
continue
}
- j := rand.Intn(i + 1)
+ j := mathrand2.IntN(i + 1)
prs[i], prs[j] = prs[j], prs[i]
}
@@ -87,7 +86,7 @@ func refresh1(ctx context.Context, log mlog.Log, resolver dns.Resolver, sleep fu
go refreshDomain(ctx, log, DB, resolver, pr)
if i < len(prs)-1 {
interval := 3 * int64(time.Hour) / int64(len(prs)-1)
- extra := time.Duration(rand.Int63n(interval) - interval/2)
+ extra := time.Duration(mathrand2.Int64N(interval) - interval/2)
next := start.Add(time.Duration(int64(i+1)*interval) + extra)
d := next.Sub(timeNow())
if d > 0 {
diff --git a/prometheus.rules b/prometheus.rules
index 1f75074d25..b134935ea4 100644
--- a/prometheus.rules
+++ b/prometheus.rules
@@ -62,9 +62,14 @@ groups:
# the alerts below can be used to keep a closer eye or when starting to use mox,
# but can be noisy, or you may not be able to prevent them.
+ - alert: mox-incoming-delivery-starttls-errors
+ expr: sum by (instance) (increase(mox_smtpserver_delivery_starttls_errors_total[1h])) / sum by (instance) (increase(mox_smtpserver_delivery_starttls_total[1h])) > 0.1
+ annotations:
+ summary: starttls handshake errors for >10% of incoming smtp delivery connections
+
# change period to match your expected incoming message rate.
- alert: mox-no-deliveries
- expr: sum(rate(mox_smtpserver_delivery_total{result="delivered"}[6h])) == 0
+ expr: sum by (instance) (rate(mox_smtpserver_delivery_total{result="delivered"}[6h])) == 0
annotations:
summary: no mail delivered for 6 hours
diff --git a/queue/hook.go b/queue/hook.go
index bc740eb9d5..700bec49bb 100644
--- a/queue/hook.go
+++ b/queue/hook.go
@@ -796,13 +796,18 @@ func Incoming(ctx context.Context, log mlog.Log, acc *store.Account, messageID s
log.Debug("composing webhook for incoming message")
+ structure, err := webhook.PartStructure(log, &part)
+ if err != nil {
+ return fmt.Errorf("parsing part structure: %v", err)
+ }
+
isIncoming = true
var rcptTo string
if m.RcptToDomain != "" {
rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
}
in := webhook.Incoming{
- Structure: webhook.PartStructure(&part),
+ Structure: structure,
Meta: webhook.IncomingMeta{
MsgID: m.ID,
MailFrom: m.MailFrom,
@@ -1119,7 +1124,7 @@ func hookDeliver(log mlog.Log, h Hook) {
} else {
backoff = hookIntervals[len(hookIntervals)-1] * time.Duration(2)
}
- backoff += time.Duration(jitter.Intn(200)-100) * backoff / 10000
+ backoff += time.Duration(jitter.IntN(200)-100) * backoff / 10000
h.Attempts++
now := time.Now()
h.NextAttempt = now.Add(backoff)
diff --git a/queue/hook_test.go b/queue/hook_test.go
index c5d7c36f33..75cebd1da3 100644
--- a/queue/hook_test.go
+++ b/queue/hook_test.go
@@ -82,6 +82,9 @@ func TestHookIncoming(t *testing.T) {
tcheck(t, err, "decode incoming webhook")
in.Meta.Received = in.Meta.Received.Local() // For TZ UTC.
+ structure, err := webhook.PartStructure(pkglog, &part)
+ tcheck(t, err, "part structure")
+
expIncoming := webhook.Incoming{
From: []webhook.NameAddress{{Address: "mjl@mox.example"}},
To: []webhook.NameAddress{{Address: "mjl@mox.example"}},
@@ -92,7 +95,7 @@ func TestHookIncoming(t *testing.T) {
Subject: "test",
Text: "test email\n",
- Structure: webhook.PartStructure(&part),
+ Structure: structure,
Meta: webhook.IncomingMeta{
MsgID: m.ID,
MailFrom: m.MailFrom,
diff --git a/queue/queue.go b/queue/queue.go
index 0a987becdb..a8ce550475 100644
--- a/queue/queue.go
+++ b/queue/queue.go
@@ -1370,7 +1370,7 @@ func deliver(log mlog.Log, resolver dns.Resolver, m0 Msg) {
return fmt.Errorf("get message to be delivered: %v", err)
}
- backoff = time.Duration(7*60+30+jitter.Intn(10)-5) * time.Second
+ backoff = time.Duration(7*60+30+jitter.IntN(10)-5) * time.Second
for i := 0; i < m0.Attempts; i++ {
backoff *= time.Duration(2)
}
diff --git a/quickstart.go b/quickstart.go
index 774003099f..4dc7e2614e 100644
--- a/quickstart.go
+++ b/quickstart.go
@@ -28,6 +28,7 @@ import (
"github.com/mjl-/sconf"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/dnsbl"
@@ -827,9 +828,9 @@ and check the admin page for the needed DNS records.`)
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
- accountConf := mox.MakeAccountConfig(addr)
+ accountConf := admin.MakeAccountConfig(addr)
const withMTASTS = true
- confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS)
+ confDomain, keyPaths, err := admin.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS)
if err != nil {
fatalf("making domain config: %s", err)
}
@@ -989,7 +990,7 @@ have been configured correctly. The DNS records to add:
// 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, "letsencrypt.org", "")
+ records, err := admin.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "")
if err != nil {
fatalf("making required DNS records")
}
diff --git a/sasl/sasl.go b/sasl/sasl.go
index f32197fdf8..22c4348461 100644
--- a/sasl/sasl.go
+++ b/sasl/sasl.go
@@ -1,4 +1,15 @@
-// Package SASL implements Simple Authentication and Security Layer, RFC 4422.
+// Package SASL implements a client for Simple Authentication and Security Layer, RFC 4422.
+//
+// Supported authentication mechanisms:
+//
+// - EXTERNAL
+// - SCRAM-SHA-256-PLUS
+// - SCRAM-SHA-1-PLUS
+// - SCRAM-SHA-256
+// - SCRAM-SHA-1
+// - CRAM-MD5
+// - PLAIN
+// - LOGIN
package sasl
import (
@@ -286,3 +297,31 @@ func (a *clientSCRAMSHA) Next(fromServer []byte) (toServer []byte, last bool, re
return nil, false, fmt.Errorf("invalid step %d", a.step)
}
}
+
+type clientExternal struct {
+ Username string
+ step int
+}
+
+var _ Client = (*clientExternal)(nil)
+
+// NewClientExternal returns a client for SASL EXTERNAL authentication.
+//
+// Username is optional.
+func NewClientExternal(username string) Client {
+ return &clientExternal{username, 0}
+}
+
+func (a *clientExternal) Info() (name string, hasCleartextCredentials bool) {
+ return "EXTERNAL", false
+}
+
+func (a *clientExternal) Next(fromServer []byte) (toServer []byte, last bool, rerr error) {
+ defer func() { a.step++ }()
+ switch a.step {
+ case 0:
+ return []byte(a.Username), true, nil
+ default:
+ return nil, false, fmt.Errorf("invalid step %d", a.step)
+ }
+}
diff --git a/scram/scram.go b/scram/scram.go
index 08532f4134..20dbf02201 100644
--- a/scram/scram.go
+++ b/scram/scram.go
@@ -1,9 +1,10 @@
-// Package scram implements the SCRAM-SHA-* SASL authentication mechanism, RFC 7677 and RFC 5802.
+// Package scram implements the SCRAM-SHA-* SASL authentication mechanisms, including the PLUS variants, RFC 7677 and RFC 5802.
//
// SCRAM-SHA-256 and SCRAM-SHA-1 allow a client to authenticate to a server using a
// password without handing plaintext password over to the server. The client also
-// verifies the server knows (a derivative of) the password. Both the client and
-// server side are implemented.
+// verifies the server knows (a derivative of) the password. The *-PLUS variants
+// bind the authentication exchange to the TLS session, preventing MitM attempts.
+// Both the client and server side are implemented.
package scram
// todo: test with messages that contains extensions
diff --git a/sendmail.go b/sendmail.go
index 041e71ae0c..fd5d12aa6c 100644
--- a/sendmail.go
+++ b/sendmail.go
@@ -3,11 +3,18 @@ package main
import (
"bufio"
"context"
+ "crypto"
+ "crypto/ed25519"
+ cryptorand "crypto/rand"
"crypto/tls"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
"errors"
"fmt"
"io"
"log"
+ "math/big"
"net"
"os"
"path/filepath"
@@ -26,17 +33,24 @@ import (
)
var submitconf struct {
- LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
- Host string `sconf-doc:"Host to dial for delivery, e.g. mail.."`
- Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
- TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
- STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
- Username string `sconf-doc:"For SMTP authentication."`
- Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."`
- AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS, SCRAM-SHA-1, CRAM-MD5, PLAIN. If not set, any mutually supported algorithm can be used, in order listed, from most to least secure. It is recommended to specify the strongest authentication mechanism known to be implemented by the server, to prevent mechanism downgrade attacks."`
- From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
- DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
- RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."`
+ LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
+ Host string `sconf-doc:"Host to dial for delivery, e.g. mail.."`
+ Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
+ TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
+ STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
+ TLSInsecureSkipVerify bool `sconf:"optional" sconf-doc:"If true, do not verify the server TLS identity."`
+ Username string `sconf-doc:"For SMTP authentication."`
+ Password string `sconf:"optional" sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."`
+ ClientAuthEd25519PrivateKey string `sconf:"optional" sconf-doc:"If set, used for TLS client authentication with a certificate. The private key must be a raw-url-base64-encoded ed25519 key. A basic certificate is composed automatically. The server must use the public key of a certificate to identify/verify users."`
+ ClientAuthCertPrivateKeyPEMFile string `sconf:"optional" sconf-doc:"If set, an absolute path to a PEM file containing both a PKCS#8 unencrypted private key and a certificate. Used for TLS client authentication."`
+ AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. EXTERNAL (for TLS client authentication), SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS, SCRAM-SHA-1, CRAM-MD5, PLAIN. If not set, any mutually supported algorithm can be used, in order listed, from most to least secure. It is recommended to specify the strongest authentication mechanism known to be implemented by the server, to prevent mechanism downgrade attacks. Exactly one of Password, ClientAuthEd25519PrivateKey and ClientAuthCertPrivateKeyPEMFile must be set."`
+ From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
+ DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
+ RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."`
+
+ // For TLS client authentication with a certificate. Either from
+ // ClientAuthEd25519PrivateKey or ClientAuthCertPrivateKeyPEMFile.
+ clientCert *tls.Certificate
}
type RequireTLSOption string
@@ -128,6 +142,71 @@ binary should be setgid that group:
err := sconf.ParseFile(confPath, &submitconf)
xcheckf(err, "parsing config")
+ var secrets []string
+ for _, s := range []string{submitconf.Password, submitconf.ClientAuthEd25519PrivateKey, submitconf.ClientAuthCertPrivateKeyPEMFile} {
+ if s != "" {
+ secrets = append(secrets, s)
+ }
+ }
+ if len(secrets) != 1 {
+ xcheckf(fmt.Errorf("got passwords/keys %s, need exactly one", strings.Join(secrets, ", ")), "checking passwords/keys")
+ }
+ if submitconf.ClientAuthEd25519PrivateKey != "" {
+ seed, err := base64.RawURLEncoding.DecodeString(submitconf.ClientAuthEd25519PrivateKey)
+ xcheckf(err, "parsing ed25519 private key")
+ if len(seed) != ed25519.SeedSize {
+ xcheckf(fmt.Errorf("got %d bytes, need %d", len(seed), ed25519.SeedSize), "parsing ed25519 private key")
+ }
+ privKey := ed25519.NewKeyFromSeed(seed)
+ _, cert := xminimalCert(privKey)
+ submitconf.clientCert = &cert
+ } else if submitconf.ClientAuthCertPrivateKeyPEMFile != "" {
+ pemBuf, err := os.ReadFile(submitconf.ClientAuthCertPrivateKeyPEMFile)
+ xcheckf(err, "reading pem file")
+ var cert tls.Certificate
+ for {
+ block, rest := pem.Decode(pemBuf)
+ if block == nil && len(rest) != 0 {
+ log.Printf("xxx, leftover data %q", rest)
+ log.Fatalf("leftover data in pem file")
+ } else if block == nil {
+ break
+ }
+ switch block.Type {
+ case "CERTIFICATE":
+ c, err := x509.ParseCertificate(block.Bytes)
+ xcheckf(err, "parsing certificate")
+ if cert.Leaf == nil {
+ cert.Leaf = c
+ }
+ cert.Certificate = append(cert.Certificate, block.Bytes)
+ case "PRIVATE KEY":
+ if cert.PrivateKey != nil {
+ log.Fatalf("cannot handle multiple private keys")
+ }
+ privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ xcheckf(err, "parsing private key")
+ cert.PrivateKey = privKey
+ default:
+ log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type)
+ }
+ pemBuf = rest
+ }
+ if len(cert.Certificate) == 0 {
+ log.Fatalf("no certificate(s) found in pem file")
+ }
+ if cert.PrivateKey == nil {
+ log.Fatalf("no private key found in pem file")
+ }
+ type cryptoPublicKey interface {
+ Equal(x crypto.PublicKey) bool
+ }
+ if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) {
+ log.Fatalf("certificate public key does not match with private key")
+ }
+ submitconf.clientCert = &cert
+ }
+
var recipient string
if len(args) == 1 && !tflag {
recipient = args[0]
@@ -257,6 +336,8 @@ binary should be setgid that group:
auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
// Check explicitly configured mechanisms.
switch submitconf.AuthMethod {
+ case "EXTERNAL":
+ return sasl.NewClientExternal(submitconf.Username), nil
case "SCRAM-SHA-256-PLUS":
if cs == nil {
return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
@@ -278,7 +359,9 @@ binary should be setgid that group:
}
// Try the defaults, from more to less secure.
- if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
+ if cs != nil && submitconf.clientCert != nil {
+ return sasl.NewClientExternal(submitconf.Username), nil
+ } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
} else if slices.Contains(mechanisms, "SCRAM-SHA-256") {
return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, true), nil
@@ -308,6 +391,9 @@ binary should be setgid that group:
} else if submitconf.RequireTLS == RequireTLSYes {
xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
}
+ if submitconf.TLSInsecureSkipVerify {
+ tlsPKIX = false
+ }
ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
xsavecheckf(err, "parsing our local hostname")
@@ -320,8 +406,9 @@ binary should be setgid that group:
// todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
opts := smtpclient.Opts{
- Auth: auth,
- RootCAs: mox.Conf.Static.TLS.CertPool,
+ Auth: auth,
+ RootCAs: mox.Conf.Static.TLS.CertPool,
+ ClientCert: submitconf.clientCert,
}
client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
xsavecheckf(err, "open smtp session")
@@ -333,3 +420,20 @@ binary should be setgid that group:
log.Printf("closing smtp session after message was sent: %v", err)
}
}
+
+func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) {
+ template := &x509.Certificate{
+ // Required field.
+ SerialNumber: big.NewInt(time.Now().Unix()),
+ }
+ certBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
+ xcheckf(err, "creating minimal certificate")
+ cert, err := x509.ParseCertificate(certBuf)
+ xcheckf(err, "parsing certificate")
+ c := tls.Certificate{
+ Certificate: [][]byte{certBuf},
+ PrivateKey: privKey,
+ Leaf: cert,
+ }
+ return certBuf, c
+}
diff --git a/serve.go b/serve.go
index 010a150f1f..870ffd4c23 100644
--- a/serve.go
+++ b/serve.go
@@ -82,6 +82,10 @@ func start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec boo
return fmt.Errorf("dmarcdb init: %s", err)
}
+ if err := store.Init(mox.Context); err != nil {
+ return fmt.Errorf("store init: %s", err)
+ }
+
done := make(chan struct{}) // Goroutines for messages and webhooks, and cleaners.
if err := queue.Start(dns.StrictResolver{Pkg: "queue"}, done); err != nil {
return fmt.Errorf("queue start: %s", err)
diff --git a/serve_unix.go b/serve_unix.go
index 1d5e772bf0..280e72e029 100644
--- a/serve_unix.go
+++ b/serve_unix.go
@@ -266,7 +266,7 @@ Only implemented on unix systems, not Windows.
if mox.Conf.Static.CheckUpdates {
checkUpdates := func() time.Duration {
next := 24 * time.Hour
- current, lastknown, mtime, err := mox.LastKnown()
+ current, lastknown, mtime, err := store.LastKnown()
if err != nil {
log.Infox("determining own version before checking for updates, trying again in 24h", err)
return next
@@ -350,7 +350,7 @@ Only implemented on unix systems, not Windows.
slog.Any("current", current),
slog.Any("lastknown", lastknown),
slog.Any("latest", latest))
- if err := mox.StoreLastKnown(latest); err != nil {
+ if err := store.StoreLastKnown(latest); err != nil {
// This will be awkward, we'll keep notifying the postmaster once every 24h...
log.Infox("updating last known version", err)
}
diff --git a/smtpclient/client.go b/smtpclient/client.go
index 238f06530e..ee7380b790 100644
--- a/smtpclient/client.go
+++ b/smtpclient/client.go
@@ -109,10 +109,11 @@ type Client struct {
tlsVerifyPKIX bool
ignoreTLSVerifyErrors bool
rootCAs *x509.CertPool
- remoteHostname dns.Domain // TLS with SNI and name verification.
- daneRecords []adns.TLSA // For authenticating (START)TLS connection.
- daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA.
- daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any.
+ remoteHostname dns.Domain // TLS with SNI and name verification.
+ daneRecords []adns.TLSA // For authenticating (START)TLS connection.
+ daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA.
+ daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any.
+ clientCert *tls.Certificate // If non-nil, tls client authentication is done.
// TLS connection success/failure are added. These are always non-nil, regardless
// of what was passed in opts. It lets us unconditionally dereference them.
@@ -226,6 +227,9 @@ type Opts struct {
// If not nil, used instead of the system default roots for TLS PKIX verification.
RootCAs *x509.CertPool
+ // If set, the TLS client certificate authentication is done.
+ ClientCert *tls.Certificate
+
// TLS verification successes/failures is added to these TLS reporting results.
// Once the STARTTLS handshake is attempted, a successful/failed connection is
// tracked.
@@ -281,6 +285,7 @@ func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode,
daneRecords: opts.DANERecords,
daneMoreHostnames: opts.DANEMoreHostnames,
daneVerifiedRecord: opts.DANEVerifiedRecord,
+ clientCert: opts.ClientCert,
lastlog: time.Now(),
cmds: []string{"(none)"},
recipientDomainResult: ensureResult(opts.RecipientDomainResult),
@@ -417,12 +422,18 @@ func (c *Client) tlsConfig() *tls.Config {
return nil
}
+ var certs []tls.Certificate
+ if c.clientCert != nil {
+ certs = []tls.Certificate{*c.clientCert}
+ }
+
return &tls.Config{
ServerName: c.remoteHostname.ASCII, // For SNI.
// todo: possibly accept older TLS versions for TLSOpportunistic? or would our private key be at risk?
MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
InsecureSkipVerify: true, // VerifyConnection below is called and will do all verification.
VerifyConnection: verifyConnection,
+ Certificates: certs,
}
}
diff --git a/smtpserver/analyze.go b/smtpserver/analyze.go
index 303f3192d9..01030cb867 100644
--- a/smtpserver/analyze.go
+++ b/smtpserver/analyze.go
@@ -601,7 +601,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
s += "junk"
}
s += fmt.Sprintf(", spamscore %.2f, threshold %.2f%s", contentProb, threshold, thresholdRemark)
- s += "(ham words: "
+ s += " (ham words: "
for i, w := range hams {
if i > 0 {
s += ", "
diff --git a/smtpserver/server.go b/smtpserver/server.go
index 39518e10f1..70b584c0d3 100644
--- a/smtpserver/server.go
+++ b/smtpserver/server.go
@@ -12,6 +12,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
+ "crypto/x509"
"encoding/base64"
"errors"
"fmt"
@@ -60,6 +61,7 @@ import (
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/store"
+ "github.com/mjl-/mox/tlsrpt"
"github.com/mjl-/mox/tlsrptdb"
)
@@ -172,6 +174,21 @@ var (
"error",
},
)
+ metricDeliveryStarttls = promauto.NewCounter(
+ prometheus.CounterOpts{
+ Name: "mox_smtpserver_delivery_starttls_total",
+ Help: "Total number of STARTTLS handshakes for incoming deliveries.",
+ },
+ )
+ metricDeliveryStarttlsErrors = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "mox_smtpserver_delivery_starttls_errors_total",
+ Help: "Errors with TLS handshake during STARTTLS for incoming deliveries.",
+ },
+ []string{
+ "reason", // "eof", "sslv2", "unsupportedversions", "nottls", "alert--", "other"
+ },
+ )
)
var jitterRand = mox.NewPseudoRand()
@@ -214,6 +231,13 @@ func Listen() http.FnALPNHelper {
port := config.Port(listener.SMTP.Port, 25)
for _, ip := range listener.IPs {
firstTimeSenderDelay := durationDefault(listener.SMTP.FirstTimeSenderDelay, firstTimeSenderDelayDefault)
+ if tlsConfigDelivery != nil {
+ tlsConfigDelivery = tlsConfigDelivery.Clone()
+ // Default setting is currently to have session tickets disabled, to work around
+ // TLS interoperability issues with incoming deliveries from Microsoft. See
+ // https://github.com/golang/go/issues/70232.
+ tlsConfigDelivery.SessionTicketsDisabled = listener.SMTP.TLSSessionTicketsDisabled == nil || *listener.SMTP.TLSSessionTicketsDisabled
+ }
listen1("smtp", name, ip, port, hostname, tlsConfigDelivery, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, !listener.SMTP.NoRequireTLS, listener.SMTP.DNSBLZones, firstTimeSenderDelay)
}
}
@@ -265,8 +289,14 @@ func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig
if err != nil {
log.Fatalx("smtp: listen for smtp", err, slog.String("protocol", protocol), slog.String("listener", name))
}
- if xtls {
- ln = tls.NewListener(ln, tlsConfig)
+
+ // Each listener gets its own copy of the config, so session keys between different
+ // ports on same listener aren't shared. We rotate session keys explicitly in this
+ // base TLS config because each connection clones the TLS config before using. The
+ // base TLS config would never get automatically managed/rotated session keys.
+ if tlsConfig != nil {
+ tlsConfig = tlsConfig.Clone()
+ mox.StartTLSSessionTicketKeyRefresher(mox.Shutdown, log, tlsConfig)
}
serve := func() {
@@ -313,7 +343,7 @@ type conn struct {
slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy.
lastlog time.Time // Used for printing the delta time since the previous logging for this connection.
submission bool // ../rfc/6409:19 applies
- tlsConfig *tls.Config
+ baseTLSConfig *tls.Config
localIP net.IP
remoteIP net.IP
hostname dns.Domain
@@ -335,6 +365,8 @@ type conn struct {
ehlo bool // If set, we had EHLO instead of HELO.
authFailed int // Number of failed auth attempts. For slowing down remote with many failures.
+ authSASL bool // Whether SASL authentication was done.
+ authTLS bool // Whether we did TLS client cert authentication.
username string // Only when authenticated.
account *store.Account // Only when authenticated.
@@ -378,17 +410,208 @@ func isClosed(err error) bool {
return errors.Is(err, errIO) || moxio.IsClosed(err)
}
+// makeTLSConfig makes a new tls config that is bound to the connection for
+// possible client certificate authentication in case of submission.
+func (c *conn) makeTLSConfig() *tls.Config {
+ if !c.submission {
+ return c.baseTLSConfig
+ }
+
+ // We clone the config so we can set VerifyPeerCertificate below to a method bound
+ // to this connection. Earlier, we set session keys explicitly on the base TLS
+ // config, so they can be used for this connection too.
+ tlsConf := c.baseTLSConfig.Clone()
+
+ // Allow client certificate authentication, for use with the sasl "external"
+ // authentication mechanism.
+ tlsConf.ClientAuth = tls.RequestClientCert
+
+ // We verify the client certificate during the handshake. The TLS handshake is
+ // initiated explicitly for incoming connections and during starttls, so we can
+ // immediately extract the account name and address used for authentication.
+ tlsConf.VerifyPeerCertificate = c.tlsClientAuthVerifyPeerCert
+
+ return tlsConf
+}
+
+// tlsClientAuthVerifyPeerCert can be used as tls.Config.VerifyPeerCertificate, and
+// sets authentication-related fields on conn. This is not called on resumed TLS
+// connections.
+func (c *conn) tlsClientAuthVerifyPeerCert(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+ if len(rawCerts) == 0 {
+ return nil
+ }
+
+ // If we had too many authentication failures from this IP, don't attempt
+ // authentication. If this is a new incoming connetion, it is closed after the TLS
+ // handshake.
+ if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) {
+ return nil
+ }
+
+ cert, err := x509.ParseCertificate(rawCerts[0])
+ if err != nil {
+ c.log.Debugx("parsing tls client certificate", err)
+ return err
+ }
+ if err := c.tlsClientAuthVerifyPeerCertParsed(cert); err != nil {
+ c.log.Debugx("verifying tls client certificate", err)
+ return fmt.Errorf("verifying client certificate: %w", err)
+ }
+ return nil
+}
+
+// tlsClientAuthVerifyPeerCertParsed verifies a client certificate. Called both for
+// fresh and resumed TLS connections.
+func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error {
+ if c.account != nil {
+ return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication")
+ }
+
+ authResult := "error"
+ defer func() {
+ metrics.AuthenticationInc("submission", "tlsclientauth", authResult)
+ if authResult == "ok" {
+ mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
+ } else {
+ mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1)
+ }
+ }()
+
+ // For many failed auth attempts, slow down verification attempts.
+ if c.authFailed > 3 && authFailDelay > 0 {
+ mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*authFailDelay)
+ }
+ c.authFailed++ // Compensated on success.
+ defer func() {
+ // On the 3rd failed authentication, start responding slowly. Successful auth will
+ // cause fast responses again.
+ if c.authFailed >= 3 {
+ c.setSlow(true)
+ }
+ }()
+
+ shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
+ fp := base64.RawURLEncoding.EncodeToString(shabuf[:])
+ pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp)
+ if err != nil {
+ if err == bstore.ErrAbsent {
+ authResult = "badcreds"
+ }
+ return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err)
+ }
+
+ // Verify account exists and still matches address.
+ acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress)
+ if err != nil {
+ return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err)
+ }
+ defer func() {
+ if acc != nil {
+ err := acc.Close()
+ c.log.Check(err, "close account")
+ }
+ }()
+ if acc.Name != pubKey.Account {
+ return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name)
+ }
+
+ authResult = "ok"
+ c.authFailed = 0
+ c.account = acc
+ acc = nil // Prevent cleanup by defer.
+ c.username = pubKey.LoginAddress
+ c.authTLS = true
+ c.log.Debug("tls client authenticated with client certificate",
+ slog.String("fingerprint", fp),
+ slog.String("username", c.username),
+ slog.String("account", c.account.Name),
+ slog.Any("remote", c.remoteIP))
+ return nil
+}
+
+// xtlsHandshakeAndAuthenticate performs the TLS handshake, and verifies a client
+// certificate if present.
+func (c *conn) xtlsHandshakeAndAuthenticate(conn net.Conn) {
+ tlsConn := tls.Server(conn, c.makeTLSConfig())
+ c.conn = tlsConn
+
+ cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid)
+ ctx, cancel := context.WithTimeout(cidctx, time.Minute)
+ defer cancel()
+ c.log.Debug("starting tls server handshake")
+ if !c.submission {
+ metricDeliveryStarttls.Inc()
+ }
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ if !c.submission {
+ // Errors from crypto/tls mostly aren't typed. We'll have to look for strings...
+ reason := "other"
+ if errors.Is(err, io.EOF) {
+ reason = "eof"
+ } else if alert, ok := mox.AsTLSAlert(err); ok {
+ reason = tlsrpt.FormatAlert(alert)
+ } else {
+ s := err.Error()
+ if strings.Contains(s, "tls: client offered only unsupported versions") {
+ reason = "unsupportedversions"
+ } else if strings.Contains(s, "tls: first record does not look like a TLS handshake") {
+ reason = "nottls"
+ } else if strings.Contains(s, "tls: unsupported SSLv2 handshake received") {
+ reason = "sslv2"
+ }
+ }
+ metricDeliveryStarttlsErrors.WithLabelValues(reason).Inc()
+ }
+ panic(fmt.Errorf("tls handshake: %s (%w)", err, errIO))
+ }
+ cancel()
+
+ cs := tlsConn.ConnectionState()
+ if cs.DidResume && len(cs.PeerCertificates) > 0 {
+ // Verify client after session resumption.
+ err := c.tlsClientAuthVerifyPeerCertParsed(cs.PeerCertificates[0])
+ if err != nil {
+ panic(fmt.Errorf("tls verify client certificate after resumption: %s (%w)", err, errIO))
+ }
+ }
+
+ attrs := []slog.Attr{
+ slog.Any("version", tlsVersion(cs.Version)),
+ slog.String("ciphersuite", tls.CipherSuiteName(cs.CipherSuite)),
+ slog.String("sni", cs.ServerName),
+ slog.Bool("resumed", cs.DidResume),
+ slog.Int("clientcerts", len(cs.PeerCertificates)),
+ }
+ if c.account != nil {
+ attrs = append(attrs,
+ slog.String("account", c.account.Name),
+ slog.String("username", c.username),
+ )
+ }
+ c.log.Debug("tls handshake completed", attrs...)
+}
+
+type tlsVersion uint16
+
+func (v tlsVersion) String() string {
+ return strings.ReplaceAll(strings.ToLower(tls.VersionName(uint16(v))), " ", "-")
+}
+
// completely reset connection state as if greeting has just been sent.
// ../rfc/3207:210
func (c *conn) reset() {
c.ehlo = false
c.hello = dns.IPDomain{}
- c.username = ""
- if c.account != nil {
- err := c.account.Close()
- c.log.Check(err, "closing account")
+ if !c.authTLS {
+ c.username = ""
+ if c.account != nil {
+ err := c.account.Close()
+ c.log.Check(err, "closing account")
+ }
+ c.account = nil
}
- c.account = nil
+ c.authSASL = false
c.rset()
}
@@ -586,7 +809,7 @@ func (c *conn) writelinef(format string, args ...any) {
var cleanClose struct{} // Sentinel value for panic/recover indicating clean close of connection.
-func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, tls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) {
+func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) {
var localIP, remoteIP net.IP
if a, ok := nc.LocalAddr().(*net.TCPAddr); ok {
localIP = a.IP
@@ -606,11 +829,11 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
origConn: nc,
conn: nc,
submission: submission,
- tls: tls,
+ tls: xtls,
extRequireTLS: requireTLS,
resolver: resolver,
lastlog: time.Now(),
- tlsConfig: tlsConfig,
+ baseTLSConfig: tlsConfig,
localIP: localIP,
remoteIP: remoteIP,
hostname: hostname,
@@ -636,8 +859,8 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
return l
})
c.tr = moxio.NewTraceReader(c.log, "RC: ", c)
- c.tw = moxio.NewTraceWriter(c.log, "LS: ", c)
c.r = bufio.NewReader(c.tr)
+ c.tw = moxio.NewTraceWriter(c.log, "LS: ", c)
c.w = bufio.NewWriter(c.tw)
metricConnection.WithLabelValues(c.kind()).Inc()
@@ -645,7 +868,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
slog.Any("remote", c.conn.RemoteAddr()),
slog.Any("local", c.conn.LocalAddr()),
slog.Bool("submission", submission),
- slog.Bool("tls", tls),
+ slog.Bool("tls", xtls),
slog.String("listener", listenerName))
defer func() {
@@ -670,6 +893,12 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
}
}()
+ if xtls {
+ // Start TLS on connection. We perform the handshake explicitly, so we can set a
+ // timeout, do client certificate authentication, log TLS details afterwards.
+ c.xtlsHandshakeAndAuthenticate(c.conn)
+ }
+
select {
case <-mox.Shutdown.Done():
// ../rfc/5321:2811 ../rfc/5321:1666 ../rfc/3463:420
@@ -898,7 +1127,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
c.bwritelinef("250-PIPELINING") // ../rfc/2920:108
c.bwritelinef("250-SIZE %d", c.maxMessageSize) // ../rfc/1870:70
// ../rfc/3207:237
- if !c.tls && c.tlsConfig != nil {
+ if !c.tls && c.baseTLSConfig != nil {
// ../rfc/3207:90
c.bwritelinef("250-STARTTLS")
} else if c.extRequireTLS {
@@ -907,6 +1136,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
c.bwritelinef("250-REQUIRETLS")
}
if c.submission {
+ var mechs string
// ../rfc/4954:123
if c.tls || !c.requireTLSForAuth {
// We always mention the SCRAM PLUS variants, even if TLS is not active: It is a
@@ -914,10 +1144,12 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
// authentication. The client should select the bare variant when TLS isn't
// present, and also not indicate the server supports the PLUS variant in that
// case, or it would trigger the mechanism downgrade detection.
- c.bwritelinef("250-AUTH SCRAM-SHA-256-PLUS SCRAM-SHA-256 SCRAM-SHA-1-PLUS SCRAM-SHA-1 CRAM-MD5 PLAIN LOGIN")
- } else {
- c.bwritelinef("250-AUTH ")
+ mechs = "SCRAM-SHA-256-PLUS SCRAM-SHA-256 SCRAM-SHA-1-PLUS SCRAM-SHA-1 CRAM-MD5 PLAIN LOGIN"
+ }
+ if c.tls && len(c.conn.(*tls.Conn).ConnectionState().PeerCertificates) > 0 {
+ mechs = "EXTERNAL " + mechs
}
+ c.bwritelinef("250-AUTH %s", mechs)
// ../rfc/4865:127
t := time.Now().Add(queue.FutureReleaseIntervalMax).UTC() // ../rfc/4865:98
c.bwritelinef("250-FUTURERELEASE %d %s", queue.FutureReleaseIntervalMax/time.Second, t.Format(time.RFC3339))
@@ -942,7 +1174,7 @@ func (c *conn) cmdStarttls(p *parser) {
if c.account != nil {
xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "cannot starttls after authentication")
}
- if c.tlsConfig == nil {
+ if c.baseTLSConfig == nil {
xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "starttls not offered")
}
@@ -960,22 +1192,8 @@ func (c *conn) cmdStarttls(p *parser) {
// We add the cid to the output, to help debugging in case of a failing TLS connection.
c.writecodeline(smtp.C220ServiceReady, smtp.SeOther00, "go! ("+mox.ReceivedID(c.cid)+")", nil)
- tlsConn := tls.Server(conn, c.tlsConfig)
- cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid)
- ctx, cancel := context.WithTimeout(cidctx, time.Minute)
- defer cancel()
- c.log.Debug("starting tls server handshake")
- if err := tlsConn.HandshakeContext(ctx); err != nil {
- panic(fmt.Errorf("starttls handshake: %s (%w)", err, errIO))
- }
- cancel()
- tlsversion, ciphersuite := moxio.TLSInfo(tlsConn)
- c.log.Debug("tls server handshake done", slog.String("tls", tlsversion), slog.String("ciphersuite", ciphersuite))
- c.conn = tlsConn
- c.tr = moxio.NewTraceReader(c.log, "RC: ", c)
- c.tw = moxio.NewTraceWriter(c.log, "LS: ", c)
- c.r = bufio.NewReader(c.tr)
- c.w = bufio.NewWriter(c.tw)
+
+ c.xtlsHandshakeAndAuthenticate(conn)
c.reset() // ../rfc/3207:210
c.tls = true
@@ -988,7 +1206,7 @@ func (c *conn) cmdAuth(p *parser) {
if !c.submission {
xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "authentication only allowed on submission ports")
}
- if c.account != nil {
+ if c.authSASL {
// ../rfc/4954:152
xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "already authenticated")
}
@@ -1021,7 +1239,7 @@ func (c *conn) cmdAuth(p *parser) {
}
}()
- var authVariant string
+ var authVariant string // Only known strings, used in metrics.
authResult := "error"
defer func() {
metrics.AuthenticationInc("submission", authVariant, authResult)
@@ -1088,6 +1306,18 @@ func (c *conn) cmdAuth(p *parser) {
return buf
}
+ // The various authentication mechanisms set account and username. We may already
+ // have an account and username from TLS client authentication. Afterwards, we
+ // check that the account is the same.
+ var account *store.Account
+ var username string
+ defer func() {
+ if account != nil {
+ err := account.Close()
+ c.log.Check(err, "close account")
+ }
+ }()
+
switch mech {
case "PLAIN":
authVariant = "plain"
@@ -1107,31 +1337,24 @@ func (c *conn) cmdAuth(p *parser) {
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "auth data should have 3 nul-separated tokens, got %d", len(plain))
}
authz := norm.NFC.String(string(plain[0]))
- authc := norm.NFC.String(string(plain[1]))
+ username = norm.NFC.String(string(plain[1]))
password := string(plain[2])
- if authz != "" && authz != authc {
+ if authz != "" && authz != username {
authResult = "badcreds"
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role")
}
- acc, err := store.OpenEmailAuth(c.log, authc, password)
+ var err error
+ account, err = store.OpenEmailAuth(c.log, username, password)
if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
// ../rfc/4954:274
authResult = "badcreds"
- c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
xcheckf(err, "verifying credentials")
- authResult = "ok"
- c.authFailed = 0
- c.setSlow(false)
- c.account = acc
- c.username = authc
- // ../rfc/4954:276
- c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
-
case "LOGIN":
// LOGIN is obsoleted in favor of PLAIN, only implemented to support legacy
// clients, see Internet-Draft (I-D):
@@ -1152,7 +1375,7 @@ func (c *conn) cmdAuth(p *parser) {
// I-D says maximum length must be 64 bytes. We allow more, for long user names
// (domains).
encChal := base64.StdEncoding.EncodeToString([]byte("Username:"))
- username := string(xreadInitial(encChal))
+ username = string(xreadInitial(encChal))
username = norm.NFC.String(username)
// Again, client should ignore the challenge, we send the same as the example in
@@ -1164,7 +1387,8 @@ func (c *conn) cmdAuth(p *parser) {
password := string(xreadContinuation())
c.xtrace(mlog.LevelTrace) // Restore.
- acc, err := store.OpenEmailAuth(c.log, username, password)
+ var err error
+ account, err = store.OpenEmailAuth(c.log, username, password)
if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
// ../rfc/4954:274
authResult = "badcreds"
@@ -1173,14 +1397,6 @@ func (c *conn) cmdAuth(p *parser) {
}
xcheckf(err, "verifying credentials")
- authResult = "ok"
- c.authFailed = 0
- c.setSlow(false)
- c.account = acc
- c.username = username
- // ../rfc/4954:276
- c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "hello ancient smtp implementation", nil)
-
case "CRAM-MD5":
authVariant = strings.ToLower(mech)
@@ -1195,26 +1411,21 @@ func (c *conn) cmdAuth(p *parser) {
if len(t) != 2 || len(t[1]) != 2*md5.Size {
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response")
}
- addr := norm.NFC.String(t[0])
- c.log.Debug("cram-md5 auth", slog.String("address", addr))
- acc, _, err := store.OpenEmail(c.log, addr)
+ username = norm.NFC.String(t[0])
+ c.log.Debug("cram-md5 auth", slog.String("username", username))
+ var err error
+ account, _, err = store.OpenEmail(c.log, username)
if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
xcheckf(err, "looking up address")
- defer func() {
- if acc != nil {
- err := acc.Close()
- c.log.Check(err, "closing account")
- }
- }()
var ipadhash, opadhash hash.Hash
- acc.WithRLock(func() {
- err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
+ account.WithRLock(func() {
+ err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
password, err := bstore.QueryTx[store.Password](tx).Get()
if err == bstore.ErrAbsent {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
if err != nil {
@@ -1229,8 +1440,8 @@ func (c *conn) cmdAuth(p *parser) {
})
if ipadhash == nil || opadhash == nil {
missingDerivedSecrets = true
- c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", addr))
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
@@ -1239,19 +1450,10 @@ func (c *conn) cmdAuth(p *parser) {
opadhash.Write(ipadhash.Sum(nil))
digest := fmt.Sprintf("%x", opadhash.Sum(nil))
if digest != t[1] {
- c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
- authResult = "ok"
- c.authFailed = 0
- c.setSlow(false)
- c.account = acc
- acc = nil // Cancel cleanup.
- c.username = addr
- // ../rfc/4954:276
- c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
-
case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1":
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
@@ -1285,31 +1487,25 @@ func (c *conn) cmdAuth(p *parser) {
c.log.Infox("scram protocol error", err, slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C455BadParams, smtp.SePol7Other0, "scram protocol error: %s", err)
}
- authc := norm.NFC.String(ss.Authentication)
- c.log.Debug("scram auth", slog.String("authentication", authc))
- acc, _, err := store.OpenEmail(c.log, authc)
+ username = norm.NFC.String(ss.Authentication)
+ c.log.Debug("scram auth", slog.String("authentication", username))
+ account, _, err = store.OpenEmail(c.log, username)
if err != nil {
// todo: we could continue scram with a generated salt, deterministically generated
// from the username. that way we don't have to store anything but attackers cannot
// learn if an account exists. same for absent scram saltedpassword below.
- c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
}
- defer func() {
- if acc != nil {
- err := acc.Close()
- c.log.Check(err, "closing account")
- }
- }()
- if ss.Authorization != "" && ss.Authorization != ss.Authentication {
+ if ss.Authorization != "" && ss.Authorization != username {
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication with authorization for different user not supported")
}
var xscram store.SCRAM
- acc.WithRLock(func() {
- err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
+ account.WithRLock(func() {
+ err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
password, err := bstore.QueryTx[store.Password](tx).Get()
if err == bstore.ErrAbsent {
- c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
}
xcheckf(err, "fetching credentials")
@@ -1323,8 +1519,8 @@ func (c *conn) cmdAuth(p *parser) {
}
if len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0 {
missingDerivedSecrets = true
- c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", authc))
- c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", username))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
}
return nil
@@ -1343,14 +1539,14 @@ func (c *conn) cmdAuth(p *parser) {
c.readline() // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) {
authResult = "badcreds"
- c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials")
} else if errors.Is(err, scram.ErrChannelBindingsDontMatch) {
authResult = "badchanbind"
- c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7MsgIntegrity7, "channel bindings do not match, potential mitm")
} else if errors.Is(err, scram.ErrInvalidEncoding) {
- c.log.Infox("bad scram protocol message", err, slog.String("username", authc), slog.Any("remote", c.remoteIP))
+ c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7Other0, "bad scram protocol message")
}
xcheckf(err, "server final")
@@ -1360,19 +1556,65 @@ func (c *conn) cmdAuth(p *parser) {
// The message should be empty. todo: should we require it is empty?
xreadContinuation()
- authResult = "ok"
- c.authFailed = 0
- c.setSlow(false)
- c.account = acc
- acc = nil // Cancel cleanup.
- c.username = authc
- // ../rfc/4954:276
- c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
+ case "EXTERNAL":
+ authVariant = strings.ToLower(mech)
+
+ // ../rfc/4422:1618
+ buf := xreadInitial("")
+ username = string(buf)
+
+ if !c.tls {
+ // ../rfc/4954:630
+ xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "tls required for tls client certificate authentication")
+ }
+ if c.account == nil {
+ xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "missing client certificate, required for tls client certificate authentication")
+ }
+
+ if username == "" {
+ username = c.username
+ }
+ var err error
+ account, _, err = store.OpenEmail(c.log, username)
+ xcheckf(err, "looking up username from tls client authentication")
default:
// ../rfc/4954:176
xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech)
}
+
+ // We may already have TLS credentials. We allow an additional SASL authentication,
+ // possibly with different username, but the account must be the same.
+ if c.account != nil {
+ if account != c.account {
+ c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection",
+ slog.String("saslmechanism", authVariant),
+ slog.String("saslaccount", account.Name),
+ slog.String("tlsaccount", c.account.Name),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ )
+ xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication failed, tls client certificate public key belongs to another account")
+ } else if username != c.username {
+ c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username",
+ slog.String("saslmechanism", authVariant),
+ slog.String("saslusername", username),
+ slog.String("tlsusername", c.username),
+ slog.String("account", c.account.Name),
+ )
+ }
+ } else {
+ c.account = account
+ account = nil // Prevent cleanup.
+ }
+ c.username = username
+
+ authResult = "ok"
+ c.authSASL = true
+ c.authFailed = 0
+ c.setSlow(false)
+ // ../rfc/4954:276
+ c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
}
// ../rfc/5321:1879 ../rfc/5321:1025
@@ -1863,6 +2105,9 @@ func (c *conn) cmdData(p *parser) {
c.msgsmtputf8 = c.isSMTPUTF8Required(part)
}
}
+ if err != nil {
+ c.log.Debugx("parsing message for smtputf8 check", err)
+ }
if c.smtputf8 != c.msgsmtputf8 {
c.log.Debug("smtputf8 flag changed", slog.Bool("smtputf8", c.smtputf8), slog.Bool("msgsmtputf8", c.msgsmtputf8))
}
diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go
index 70b12be0f4..d058142951 100644
--- a/smtpserver/server_test.go
+++ b/smtpserver/server_test.go
@@ -82,19 +82,23 @@ test email, unique.
`, "\n", "\r\n")
type testserver struct {
- t *testing.T
- acc *store.Account
- switchStop func()
- comm *store.Comm
- cid int64
- resolver dns.Resolver
- auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)
- user, pass string
- submission bool
- requiretls bool
- dnsbls []dns.Domain
- tlsmode smtpclient.TLSMode
- tlspkix bool
+ t *testing.T
+ acc *store.Account
+ switchStop func()
+ comm *store.Comm
+ cid int64
+ resolver dns.Resolver
+ auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)
+ user, pass string
+ immediateTLS bool
+ serverConfig *tls.Config
+ clientConfig *tls.Config
+ clientCert *tls.Certificate // Passed to smtpclient for starttls authentication.
+ submission bool
+ requiretls bool
+ dnsbls []dns.Domain
+ tlsmode smtpclient.TLSMode
+ tlspkix bool
}
const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
@@ -103,9 +107,23 @@ const password1 = "tést " // PRECIS normalized, with NF
func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *testserver {
limitersInit() // Reset rate limiters.
- ts := testserver{t: t, cid: 1, resolver: resolver, tlsmode: smtpclient.TLSOpportunistic}
-
log := mlog.New("smtpserver", nil)
+
+ ts := testserver{
+ t: t,
+ cid: 1,
+ resolver: resolver,
+ tlsmode: smtpclient.TLSOpportunistic,
+ serverConfig: &tls.Config{
+ Certificates: []tls.Certificate{fakeCert(t, false)},
+ },
+ }
+
+ // Ensure session keys, for tests that check resume and authentication.
+ ctx, cancel := context.WithCancel(ctxbg)
+ defer cancel()
+ mox.StartTLSSessionTicketKeyRefresher(ctx, log, ts.serverConfig)
+
mox.Context = ctxbg
mox.ConfigStaticPath = configPath
mox.MustLoadConfig(true, false)
@@ -116,6 +134,8 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test
tcheck(t, err, "dmarcdb init")
err = tlsrptdb.Init()
tcheck(t, err, "tlsrptdb init")
+ err = store.Init(ctxbg)
+ tcheck(t, err, "store init")
ts.acc, err = store.OpenAccount(log, "mjl")
tcheck(t, err, "open account")
@@ -139,6 +159,8 @@ func (ts *testserver) close() {
tcheck(ts.t, err, "dmarcdb close")
err = tlsrptdb.Close()
tcheck(ts.t, err, "tlsrptdb close")
+ err = store.Close()
+ tcheck(ts.t, err, "store close")
ts.comm.Unregister()
queue.Shutdown()
ts.switchStop()
@@ -180,8 +202,9 @@ func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
ourHostname := mox.Conf.Static.HostnameDomain
remoteHostname := dns.Domain{ASCII: "mox.example"}
opts := smtpclient.Opts{
- Auth: auth,
- RootCAs: mox.Conf.Static.TLS.CertPool,
+ Auth: auth,
+ RootCAs: mox.Conf.Static.TLS.CertPool,
+ ClientCert: ts.clientCert,
}
log := pkglog.WithCid(ts.cid - 1)
client, err := smtpclient.New(ctxbg, log.Logger, conn, ts.tlsmode, ts.tlspkix, ourHostname, remoteHostname, opts)
@@ -206,13 +229,14 @@ func (ts *testserver) runRaw(fn func(clientConn net.Conn)) {
defer func() { <-serverdone }()
go func() {
- tlsConfig := &tls.Config{
- Certificates: []tls.Certificate{fakeCert(ts.t)},
- }
- serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.requiretls, ts.dnsbls, 0)
+ serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, ts.serverConfig, serverConn, ts.resolver, ts.submission, ts.immediateTLS, 100<<20, false, false, ts.requiretls, ts.dnsbls, 0)
close(serverdone)
}()
+ if ts.immediateTLS {
+ clientConn = tls.Client(clientConn, ts.clientConfig)
+ }
+
fn(clientConn)
}
@@ -228,10 +252,17 @@ func (ts *testserver) smtpErr(err error, expErr *smtpclient.Error) {
// Just a cert that appears valid. SMTP client will not verify anything about it
// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
// one moment where it makes life easier.
-func fakeCert(t *testing.T) tls.Certificate {
- privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
+func fakeCert(t *testing.T, randomkey bool) tls.Certificate {
+ seed := make([]byte, ed25519.SeedSize)
+ if randomkey {
+ cryptorand.Read(seed)
+ }
+ privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
template := &x509.Certificate{
SerialNumber: big.NewInt(1), // Required field...
+ // Valid period is needed to get session resumption enabled.
+ NotBefore: time.Now().Add(-time.Minute),
+ NotAfter: time.Now().Add(time.Hour),
}
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
if err != nil {
@@ -330,6 +361,108 @@ func TestSubmission(t *testing.T) {
testAuth(fn, "mo\u0301x@mox.example", password0, nil)
testAuth(fn, "mo\u0301x@mox.example", password1, nil)
}
+
+ // Create a certificate, register its public key with account, and make a tls
+ // client config that sends the certificate.
+ clientCert0 := fakeCert(ts.t, true)
+ tlspubkey, err := store.ParseTLSPublicKeyCert(clientCert0.Certificate[0])
+ tcheck(t, err, "parse certificate")
+ tlspubkey.Account = "mjl"
+ tlspubkey.LoginAddress = "mjl@mox.example"
+ err = store.TLSPublicKeyAdd(ctxbg, &tlspubkey)
+ tcheck(t, err, "add tls public key to account")
+ ts.immediateTLS = true
+ ts.clientConfig = &tls.Config{
+ InsecureSkipVerify: true,
+ Certificates: []tls.Certificate{
+ clientCert0,
+ },
+ }
+
+ // No explicit address in EXTERNAL.
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientExternal(user)
+ }, "", "", nil)
+
+ // Same username in EXTERNAL as configured for key.
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientExternal(user)
+ }, "mjl@mox.example", "", nil)
+
+ // Different username in EXTERNAL as configured for key, but same account.
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientExternal(user)
+ }, "móx@mox.example", "", nil)
+
+ // Different username as configured for key, but same account, but not EXTERNAL auth.
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientSCRAMSHA256PLUS(user, pass, *cs)
+ }, "móx@mox.example", password0, nil)
+
+ // Different account results in error.
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientExternal(user)
+ }, "☺@mox.example", "", &smtpclient.Error{Code: smtp.C535AuthBadCreds, Secode: smtp.SePol7AuthBadCreds8})
+
+ // Starttls with client cert should authenticate too.
+ ts.immediateTLS = false
+ ts.clientCert = &clientCert0
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ return sasl.NewClientExternal(user)
+ }, "", "", nil)
+ ts.immediateTLS = true
+ ts.clientCert = nil
+
+ // Add a client session cache, so our connections will be resumed. We are testing
+ // that the credentials are applied to resumed connections too.
+ ts.clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10)
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ if cs.DidResume {
+ panic("tls connection was resumed")
+ }
+ return sasl.NewClientExternal(user)
+ }, "", "", nil)
+ testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client {
+ if !cs.DidResume {
+ panic("tls connection was not resumed")
+ }
+ return sasl.NewClientExternal(user)
+ }, "", "", nil)
+
+ // Unknown client certificate should fail the connection.
+ serverConn, clientConn := net.Pipe()
+ serverdone := make(chan struct{})
+ defer func() { <-serverdone }()
+
+ go func() {
+ defer serverConn.Close()
+ tlsConfig := &tls.Config{
+ Certificates: []tls.Certificate{fakeCert(ts.t, false)},
+ }
+ serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, ts.immediateTLS, 100<<20, false, false, false, ts.dnsbls, 0)
+ close(serverdone)
+ }()
+
+ defer clientConn.Close()
+
+ // Authentication with an unknown/untrusted certificate should fail.
+ clientCert1 := fakeCert(ts.t, true)
+ ts.clientConfig.ClientSessionCache = nil
+ ts.clientConfig.Certificates = []tls.Certificate{
+ clientCert1,
+ }
+ clientConn = tls.Client(clientConn, ts.clientConfig)
+ // note: It's not enough to do a handshake and check if that was successful. If the
+ // client cert is not acceptable, we only learn after the handshake, when the first
+ // data messages are exchanged.
+ buf := make([]byte, 100)
+ _, err = clientConn.Read(buf)
+ if err == nil {
+ t.Fatalf("tls handshake with unknown client certificate succeeded")
+ }
+ if alert, ok := mox.AsTLSAlert(err); !ok || alert != 42 {
+ t.Fatalf("got err %#v, expected tls 'bad certificate' alert", err)
+ }
}
// Test delivery from external MTA.
@@ -1247,7 +1380,7 @@ func TestNonSMTP(t *testing.T) {
go func() {
tlsConfig := &tls.Config{
- Certificates: []tls.Certificate{fakeCert(ts.t)},
+ Certificates: []tls.Certificate{fakeCert(ts.t, false)},
}
serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, false, ts.dnsbls, 0)
close(serverdone)
diff --git a/mox-/lastknown.go b/store/lastknown.go
similarity index 81%
rename from mox-/lastknown.go
rename to store/lastknown.go
index b1a0da9db8..ef8a1b8489 100644
--- a/mox-/lastknown.go
+++ b/store/lastknown.go
@@ -1,4 +1,4 @@
-package mox
+package store
import (
"fmt"
@@ -6,6 +6,7 @@ import (
"strings"
"time"
+ "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/updates"
)
@@ -13,15 +14,15 @@ import (
// StoreLastKnown stores the the last known version. Future update checks compare
// against it, or the currently running version, whichever is newer.
func StoreLastKnown(v updates.Version) error {
- return os.WriteFile(DataDirPath("lastknownversion"), []byte(v.String()), 0660)
+ return os.WriteFile(mox.DataDirPath("lastknownversion"), []byte(v.String()), 0660)
}
// LastKnown returns the last known version that has been mentioned in an update
// email, or the current application.
func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) {
- curv, curerr := updates.ParseVersion(moxvar.Version)
+ curv, curerr := updates.ParseVersion(moxvar.VersionBare)
- p := DataDirPath("lastknownversion")
+ p := mox.DataDirPath("lastknownversion")
fi, _ := os.Stat(p)
if fi != nil {
mtime = fi.ModTime()
@@ -44,7 +45,7 @@ func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr erro
} else if lasterr == nil {
return curv, lastknown, mtime, nil
}
- if moxvar.Version == "(devel)" {
+ if strings.HasPrefix(moxvar.Version, "(devel)") {
return curv, updates.Version{}, mtime, fmt.Errorf("development version")
}
return curv, updates.Version{}, mtime, fmt.Errorf("parsing version: %w", err)
diff --git a/store/tlspubkey.go b/store/tlspubkey.go
new file mode 100644
index 0000000000..3d6618323f
--- /dev/null
+++ b/store/tlspubkey.go
@@ -0,0 +1,168 @@
+package store
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mjl-/bstore"
+
+ "github.com/mjl-/mox/mlog"
+ "github.com/mjl-/mox/mox-"
+ "github.com/mjl-/mox/smtp"
+)
+
+// TLSPublicKey is a public key for use with TLS client authentication based on the
+// public key of the certificate.
+type TLSPublicKey struct {
+ // Raw-url-base64-encoded Subject Public Key Info of certificate.
+ Fingerprint string
+ Created time.Time `bstore:"nonzero,default now"`
+ Type string // E.g. "rsa-2048", "ecdsa-p256", "ed25519"
+
+ // Descriptive name to identify the key, e.g. the device where key is used.
+ Name string `bstore:"nonzero"`
+
+ // If set, new immediate authenticated TLS connections are not moved to
+ // "authenticated" state. For clients that don't understand it, and will try an
+ // authenticate command anyway.
+ NoIMAPPreauth bool
+
+ CertDER []byte `bstore:"nonzero"`
+ Account string `bstore:"nonzero"` // Key authenticates this account.
+ LoginAddress string `bstore:"nonzero"` // Must belong to account.
+}
+
+// AuthDB and AuthDBTypes are exported for ../backup.go.
+var AuthDB *bstore.DB
+var AuthDBTypes = []any{TLSPublicKey{}}
+
+// Init opens auth.db.
+func Init(ctx context.Context) error {
+ if AuthDB != nil {
+ return fmt.Errorf("already initialized")
+ }
+ pkglog := mlog.New("store", nil)
+ p := mox.DataDirPath("auth.db")
+ os.MkdirAll(filepath.Dir(p), 0770)
+ opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: pkglog.Logger}
+ var err error
+ AuthDB, err = bstore.Open(ctx, p, &opts, AuthDBTypes...)
+ return err
+}
+
+// Close closes auth.db.
+func Close() error {
+ if AuthDB == nil {
+ return fmt.Errorf("not open")
+ }
+ err := AuthDB.Close()
+ AuthDB = nil
+ return err
+}
+
+// ParseTLSPublicKeyCert parses a certificate, preparing a TLSPublicKey for
+// insertion into the database. Caller must set fields that are not in the
+// certificat, such as Account and LoginAddress.
+func ParseTLSPublicKeyCert(certDER []byte) (TLSPublicKey, error) {
+ cert, err := x509.ParseCertificate(certDER)
+ if err != nil {
+ return TLSPublicKey{}, fmt.Errorf("parsing certificate: %v", err)
+ }
+ name := cert.Subject.CommonName
+ if name == "" && cert.SerialNumber != nil {
+ name = fmt.Sprintf("serial %x", cert.SerialNumber.Bytes())
+ }
+
+ buf := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
+ fp := base64.RawURLEncoding.EncodeToString(buf[:])
+ var typ string
+ switch k := cert.PublicKey.(type) {
+ case *rsa.PublicKey:
+ bits := k.N.BitLen()
+ if bits < 2048 {
+ return TLSPublicKey{}, fmt.Errorf("rsa keys smaller than 2048 bits not accepted")
+ }
+ typ = "rsa-" + fmt.Sprintf("%d", bits)
+ case *ecdsa.PublicKey:
+ typ = "ecdsa-" + strings.ReplaceAll(strings.ToLower(k.Params().Name), "-", "")
+ case ed25519.PublicKey:
+ typ = "ed25519"
+ default:
+ return TLSPublicKey{}, fmt.Errorf("public key type %T not implemented", cert.PublicKey)
+ }
+
+ return TLSPublicKey{Fingerprint: fp, Type: typ, Name: name, CertDER: certDER}, nil
+}
+
+// TLSPublicKeyList returns tls public keys. If accountOpt is empty, keys for all
+// accounts are returned.
+func TLSPublicKeyList(ctx context.Context, accountOpt string) ([]TLSPublicKey, error) {
+ q := bstore.QueryDB[TLSPublicKey](ctx, AuthDB)
+ if accountOpt != "" {
+ q.FilterNonzero(TLSPublicKey{Account: accountOpt})
+ }
+ return q.List()
+}
+
+// TLSPublicKeyGet retrieves a single tls public key by fingerprint.
+// If absent, bstore.ErrAbsent is returned.
+func TLSPublicKeyGet(ctx context.Context, fingerprint string) (TLSPublicKey, error) {
+ pubKey := TLSPublicKey{Fingerprint: fingerprint}
+ err := AuthDB.Get(ctx, &pubKey)
+ return pubKey, err
+}
+
+// TLSPublicKeyAdd adds a new tls public key.
+//
+// Caller is responsible for checking the account and email address are valid.
+func TLSPublicKeyAdd(ctx context.Context, pubKey *TLSPublicKey) error {
+ if err := checkTLSPublicKeyAddress(pubKey.LoginAddress); err != nil {
+ return err
+ }
+ return AuthDB.Insert(ctx, pubKey)
+}
+
+// TLSPublicKeyUpdate updates an existing tls public key.
+//
+// Caller is responsible for checking the account and email address are valid.
+func TLSPublicKeyUpdate(ctx context.Context, pubKey *TLSPublicKey) error {
+ if err := checkTLSPublicKeyAddress(pubKey.LoginAddress); err != nil {
+ return err
+ }
+ return AuthDB.Update(ctx, pubKey)
+}
+
+func checkTLSPublicKeyAddress(addr string) error {
+ a, err := smtp.ParseAddress(addr)
+ if err != nil {
+ return fmt.Errorf("parsing login address %q: %v", addr, err)
+ }
+ if a.String() != addr {
+ return fmt.Errorf("login address %q must be specified in canonical form %q", addr, a.String())
+ }
+ return nil
+}
+
+// TLSPublicKeyRemove removes a tls public key.
+func TLSPublicKeyRemove(ctx context.Context, fingerprint string) error {
+ k := TLSPublicKey{Fingerprint: fingerprint}
+ return AuthDB.Delete(ctx, &k)
+}
+
+// TLSPublicKeyRemoveForAccount removes all tls public keys for an account.
+func TLSPublicKeyRemoveForAccount(ctx context.Context, account string) error {
+ q := bstore.QueryDB[TLSPublicKey](ctx, AuthDB)
+ q.FilterNonzero(TLSPublicKey{Account: account})
+ _, err := q.Delete()
+ return err
+}
diff --git a/testdata/imap/domains.conf b/testdata/imap/domains.conf
index 320ded3d9f..b04545d825 100644
--- a/testdata/imap/domains.conf
+++ b/testdata/imap/domains.conf
@@ -15,6 +15,10 @@ Accounts:
MaxPower: 0.1
TopWords: 10
IgnoreWords: 0.1
+ other:
+ Domain: mox.example
+ Destinations:
+ other@mox.example: nil
limit:
Domain: mox.example
Destinations:
diff --git a/tlsrpt/alert.go b/tlsrpt/alert.go
index 3f4ca8b8ca..227bb2af8e 100644
--- a/tlsrpt/alert.go
+++ b/tlsrpt/alert.go
@@ -10,7 +10,8 @@ import (
"strings"
)
-func formatAlert(alert uint8) string {
+// FormatAlert formats a TLS alert in the form "alert-" or "alert--".
+func FormatAlert(alert uint8) string {
s := fmt.Sprintf("alert-%d", alert)
err := tls.AlertError(alert) // Since go1.21.0
// crypto/tls returns messages like "tls: short message" or "tls: alert(321)".
diff --git a/tlsrpt/alert_go120.go b/tlsrpt/alert_go120.go
index c686a523c2..a2a396add9 100644
--- a/tlsrpt/alert_go120.go
+++ b/tlsrpt/alert_go120.go
@@ -8,6 +8,7 @@ import (
"fmt"
)
-func formatAlert(alert uint8) string {
+// FormatAlert formats a TLS alert in the form "alert-".
+func FormatAlert(alert uint8) string {
return fmt.Sprintf("alert-%d", alert)
}
diff --git a/tlsrpt/report.go b/tlsrpt/report.go
index 5e09d331dd..a25cc377cb 100644
--- a/tlsrpt/report.go
+++ b/tlsrpt/report.go
@@ -394,7 +394,7 @@ func TLSFailureDetails(err error) (ResultType, string) {
// todo: ideally, crypto/tls would let us check if this is an alert. it could be another uint8-typed error.
v := reflect.ValueOf(netErr.Err)
if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
- reasonCode = "tls-remote-" + formatAlert(uint8(v.Uint()))
+ reasonCode = "tls-remote-" + FormatAlert(uint8(v.Uint()))
}
}
return ResultValidationFailure, reasonCode
@@ -429,7 +429,7 @@ func TLSFailureDetails(err error) (ResultType, string) {
}
v := reflect.ValueOf(err)
if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
- reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
+ reasonCode = "tls-local-" + FormatAlert(uint8(v.Uint()))
}
}
return ResultValidationFailure, reasonCode
diff --git a/tlsrptsend/send.go b/tlsrptsend/send.go
index 69a05bd57d..c7c34a642d 100644
--- a/tlsrptsend/send.go
+++ b/tlsrptsend/send.go
@@ -73,7 +73,7 @@ var jitterRand = mox.NewPseudoRand()
// Jitter so we don't cause load at exactly midnight, other processes may
// already be doing that.
var jitteredTimeUntil = func(t time.Time) time.Duration {
- return time.Until(t.Add(time.Duration(240+jitterRand.Intn(120)) * time.Second))
+ return time.Until(t.Add(time.Duration(240+jitterRand.IntN(120)) * time.Second))
}
// Start launches a goroutine that wakes up just after 00:00 UTC to send TLSRPT
diff --git a/verifydata.go b/verifydata.go
index 9d01e447e7..9d9081d617 100644
--- a/verifydata.go
+++ b/verifydata.go
@@ -422,7 +422,7 @@ possibly making them potentially no longer readable by the previous version.
p = p[len(dataDir)+1:]
}
switch p {
- case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "lastknownversion":
+ case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "lastknownversion":
return nil
case "acme", "queue", "accounts", "tmp", "moved":
return fs.SkipDir
@@ -440,6 +440,7 @@ possibly making them potentially no longer readable by the previous version.
checkf(err, dataDir, "walking data directory")
}
+ checkDB(false, filepath.Join(dataDir, "auth.db"), store.AuthDBTypes) // Since v0.0.14.
checkDB(true, filepath.Join(dataDir, "dmarcrpt.db"), dmarcdb.ReportsDBTypes)
checkDB(false, filepath.Join(dataDir, "dmarceval.db"), dmarcdb.EvalDBTypes) // After v0.0.7.
checkDB(true, filepath.Join(dataDir, "mtasts.db"), mtastsdb.DBTypes)
diff --git a/webaccount/account.go b/webaccount/account.go
index ec2f4e0921..c8e7a07f75 100644
--- a/webaccount/account.go
+++ b/webaccount/account.go
@@ -8,6 +8,7 @@ import (
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
+ "encoding/pem"
"errors"
"fmt"
"io"
@@ -26,6 +27,7 @@ import (
"github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom"
+ "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
@@ -51,10 +53,11 @@ var accountHTML []byte
var accountJS []byte
var webaccountFile = &mox.WebappFile{
- HTML: accountHTML,
- JS: accountJS,
- HTMLPath: filepath.FromSlash("webaccount/account.html"),
- JSPath: filepath.FromSlash("webaccount/account.js"),
+ HTML: accountHTML,
+ JS: accountJS,
+ HTMLPath: filepath.FromSlash("webaccount/account.html"),
+ JSPath: filepath.FromSlash("webaccount/account.js"),
+ CustomStem: "webaccount",
}
var accountDoc = mustParseAPI("account", accountapiJSON)
@@ -109,7 +112,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
return
}
// If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
- if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) {
+ if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
xcheckuserf(ctx, err, format, args...)
}
@@ -432,7 +435,7 @@ func (Account) Account(ctx context.Context) (account config.Account, storageUsed
// for the account.
func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FullName = fullName
})
xcheckf(ctx, err, "saving account full name")
@@ -444,7 +447,7 @@ func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
curDest, ok := conf.Destinations[destName]
if !ok {
xcheckuserf(ctx, errors.New("not found"), "looking up destination")
@@ -526,7 +529,7 @@ func (Account) SuppressionRemove(ctx context.Context, address string) {
// to be delivered, or all if empty/nil.
func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" {
acc.OutgoingWebhook = nil
} else {
@@ -565,7 +568,7 @@ func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization st
// the Authorization header in requests.
func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" {
acc.IncomingWebhook = nil
} else {
@@ -610,7 +613,7 @@ func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization st
// MAIL FROM addresses ("fromid") for deliveries from the queue.
func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FromIDLoginAddresses = loginAddresses
})
xcheckf(ctx, err, "saving account fromid login addresses")
@@ -619,7 +622,7 @@ func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []st
// KeepRetiredPeriodsSave saves periods to save retired messages and webhooks.
func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod
acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod
})
@@ -630,7 +633,7 @@ func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePer
// junk/nonjunk when moved to mailboxes matching certain regular expressions.
func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.AutomaticJunkFlags = config.AutomaticJunkFlags{
Enabled: enabled,
JunkMailboxRegexp: junkRegexp,
@@ -645,7 +648,7 @@ func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkReg
// is disabled. Otherwise all fields except Threegrams are stored.
func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if junkFilter == nil {
acc.JunkFilter = nil
return
@@ -663,9 +666,86 @@ func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter
// RejectsSave saves the RejectsMailbox and KeepRejects settings.
func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
- err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
+ err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.RejectsMailbox = mailbox
acc.KeepRejects = keep
})
xcheckf(ctx, err, "saving account rejects settings")
}
+
+func (Account) TLSPublicKeys(ctx context.Context) ([]store.TLSPublicKey, error) {
+ reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
+ return store.TLSPublicKeyList(ctx, reqInfo.AccountName)
+}
+
+func (Account) TLSPublicKeyAdd(ctx context.Context, loginAddress, name string, noIMAPPreauth bool, certPEM string) (store.TLSPublicKey, error) {
+ reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
+
+ block, rest := pem.Decode([]byte(certPEM))
+ var err error
+ if block == nil {
+ err = errors.New("no pem data found")
+ } else if block.Type != "CERTIFICATE" {
+ err = fmt.Errorf("unexpected type %q, need CERTIFICATE", block.Type)
+ } else if len(rest) != 0 {
+ err = errors.New("only single pem block allowed")
+ }
+ xcheckuserf(ctx, err, "parsing pem file")
+
+ tpk, err := store.ParseTLSPublicKeyCert(block.Bytes)
+ xcheckuserf(ctx, err, "parsing certificate")
+ if name != "" {
+ tpk.Name = name
+ }
+ tpk.Account = reqInfo.AccountName
+ tpk.LoginAddress = loginAddress
+ tpk.NoIMAPPreauth = noIMAPPreauth
+ err = store.TLSPublicKeyAdd(ctx, &tpk)
+ if err != nil && errors.Is(err, bstore.ErrUnique) {
+ xcheckuserf(ctx, err, "add tls public key")
+ } else {
+ xcheckf(ctx, err, "add tls public key")
+ }
+ return tpk, nil
+}
+
+func xtlspublickey(ctx context.Context, account string, fingerprint string) store.TLSPublicKey {
+ tpk, err := store.TLSPublicKeyGet(ctx, fingerprint)
+ if err == nil && tpk.Account != account {
+ err = bstore.ErrAbsent
+ }
+ if err == bstore.ErrAbsent {
+ xcheckuserf(ctx, err, "get tls public key")
+ }
+ xcheckf(ctx, err, "get tls public key")
+ return tpk
+}
+
+func (Account) TLSPublicKeyRemove(ctx context.Context, fingerprint string) error {
+ reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
+ xtlspublickey(ctx, reqInfo.AccountName, fingerprint)
+ return store.TLSPublicKeyRemove(ctx, fingerprint)
+}
+
+func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey) error {
+ reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
+ tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint)
+ log := pkglog.WithContext(ctx)
+ acc, _, err := store.OpenEmail(log, pubKey.LoginAddress)
+ if err == nil && acc.Name != reqInfo.AccountName {
+ err = store.ErrUnknownCredentials
+ }
+ if acc != nil {
+ xerr := acc.Close()
+ log.Check(xerr, "close account")
+ }
+ if err == store.ErrUnknownCredentials {
+ xcheckuserf(ctx, errors.New("unknown address"), "looking up address")
+ }
+ tpk.Name = pubKey.Name
+ tpk.LoginAddress = pubKey.LoginAddress
+ tpk.NoIMAPPreauth = pubKey.NoIMAPPreauth
+ err = store.TLSPublicKeyUpdate(ctx, &tpk)
+ xcheckf(ctx, err, "updating tls public key")
+ return nil
+}
diff --git a/webaccount/account.html b/webaccount/account.html
index 4cd6fee052..0c18d503c9 100644
--- a/webaccount/account.html
+++ b/webaccount/account.html
@@ -30,10 +30,14 @@
.autosize { display: inline-grid; max-width: 90vw; }
.autosize.input { grid-area: 1 / 2; }
.autosize::after { content: attr(data-value); margin-right: 1em; line-height: 0; visibility: hidden; white-space: pre-wrap; overflow-x: hidden; }
+
+/* css placeholder */
-
+