Skip to content

Commit

Permalink
implement tls client certificate authentication
Browse files Browse the repository at this point in the history
the imap & smtp servers now allow logging in with tls client authentication and
the "external" sasl authentication mechanism. email clients like thunderbird,
fairemail, k9, macos mail implement it. this seems to be the most secure among
the authentication mechanism commonly implemented by clients. a useful property
is that an account can have a separate tls public key for each device/email
client.  with tls client cert auth, authentication is also bound to the tls
connection. a mitm cannot pass the credentials on to another tls connection,
similar to scram-*-plus. though part of scram-*-plus is that clients verify
that the server knows the client credentials.

for tls client auth with imap, we send a "preauth" untagged message by default.
that puts the connection in authenticated state. given the imap connection
state machine, further authentication commands are not allowed. some clients
don't recognize the preauth message, and try to authenticate anyway, which
fails. a tls public key has a config option to disable preauth, keeping new
connections in unauthenticated state, to work with such email clients.

for smtp (submission), we don't require an explicit auth command.

both for imap and smtp, we allow a client to authenticate with another
mechanism than "external". in that case, credentials are verified, and have to
be for the same account as the tls client auth, but the adress can be another
one than the login address configured with the tls public key.

only the public key is used to identify the account that is authenticating. we
ignore the rest of the certificate. expiration dates, names, constraints, etc
are not verified. no certificate authorities are involved.

users can upload their own (minimal) certificate. the account web interface
shows openssl commands you can run to generate a private key, minimal cert, and
a p12 file (the format that email clients seem to like...) containing both
private key and certificate.

the imapclient & smtpclient packages can now also use tls client auth. and so
does "mox sendmail", either with a pem file with private key and certificate,
or with just an ed25519 private key.

there are new subcommands "mox config tlspubkey ..." for
adding/removing/listing tls public keys from the cli, by the admin.
  • Loading branch information
mjl- committed Dec 6, 2024
1 parent 5f7831a commit 8804d6b
Show file tree
Hide file tree
Showing 38 changed files with 2,737 additions and 309 deletions.
47 changes: 42 additions & 5 deletions admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/store"
)

var pkglog = mlog.New("admin", nil)
Expand Down Expand Up @@ -514,6 +515,18 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
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
Expand Down Expand Up @@ -720,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
}
Expand Down Expand Up @@ -851,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
Expand All @@ -867,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.
Expand All @@ -876,10 +900,6 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if strings.HasPrefix(address, "@") {
continue
}
dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
if !ok {
return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
}
flp := mox.CanonicalLocalpart(fa.Localpart, dc)
alp := mox.CanonicalLocalpart(pa.Localpart, dc)
if alp != flp {
Expand All @@ -889,6 +909,23 @@ 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(mox.Conf.Dynamic.Domains)
for _, aa := range na.Aliases {
Expand Down
3 changes: 2 additions & 1 deletion backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions ctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -1015,6 +1016,94 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
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"
Expand Down
57 changes: 57 additions & 0 deletions ctl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ package main

import (
"context"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/x509"
"flag"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -334,6 +342,43 @@ func TestCtl(t *testing.T) {
ctlcmdConfigAliasRemove(ctl, "[email protected]")
})

// accounttlspubkeyadd
certDER := fakeCert(t)
testctl(func(ctl *ctl) {
ctlcmdConfigTlspubkeyAdd(ctl, "[email protected]", "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)
Expand Down Expand Up @@ -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
}
60 changes: 60 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions gentestdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]"})
xcheckf(err, "adding tlspubkey")

// Populate dmarc.db.
err = dmarcdb.Init()
xcheckf(err, "dmarcdb init")
Expand Down
Loading

0 comments on commit 8804d6b

Please sign in to comment.