implement tls client certificate authentication

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.
This commit is contained in:
Mechiel Lukkien
2024-12-05 22:41:49 +01:00
parent 5f7831a7f0
commit 8804d6b60e
38 changed files with 2737 additions and 309 deletions

199
main.go
View File

@ -150,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},
@ -921,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.