mirror of
https://github.com/mjl-/mox.git
synced 2024-12-25 16:03:48 +03:00
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:
parent
5f7831a7f0
commit
8804d6b60e
38 changed files with 2737 additions and 309 deletions
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
89
ctl.go
89
ctl.go
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
@ -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"
|
||||
|
|
57
ctl_test.go
57
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
|
||||
}
|
||||
|
|
60
doc.go
60
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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ import (
|
|||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -147,6 +148,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
|
||||
|
@ -175,7 +177,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.
|
||||
|
@ -193,8 +195,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.
|
||||
|
@ -355,8 +361,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() {
|
||||
|
@ -637,7 +649,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{},
|
||||
|
@ -660,19 +672,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 {
|
||||
|
@ -709,6 +717,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
|
||||
|
@ -742,7 +756,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)
|
||||
|
||||
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()
|
||||
|
@ -756,6 +775,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
|
||||
|
@ -1361,7 +1546,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 {
|
||||
|
@ -1369,6 +1554,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
|
||||
}
|
||||
|
||||
|
@ -1454,7 +1642,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")
|
||||
}
|
||||
|
||||
|
@ -1468,30 +1656,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
|
||||
|
@ -1519,7 +1697,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)
|
||||
|
@ -1573,6 +1751,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"
|
||||
|
@ -1591,24 +1781,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)
|
||||
|
@ -1625,28 +1814,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 {
|
||||
|
@ -1660,8 +1844,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")
|
||||
}
|
||||
|
@ -1671,14 +1855,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
|
||||
|
@ -1711,29 +1891,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")
|
||||
|
@ -1747,7 +1922,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
|
||||
|
@ -1766,14 +1941,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)
|
||||
|
@ -1783,18 +1958,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())
|
||||
}
|
||||
|
@ -1808,13 +2030,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()
|
||||
|
@ -1837,21 +2064,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())
|
||||
|
|
|
@ -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)
|
||||
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 {
|
||||
|
|
199
main.go
199
main.go
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
23
mox-/tlsalert.go
Normal file
23
mox-/tlsalert.go
Normal file
|
@ -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
|
||||
}
|
51
mox-/tlssessionticket.go
Normal file
51
mox-/tlssessionticket.go
Normal file
|
@ -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
|
||||
}
|
28
sasl/sasl.go
28
sasl/sasl.go
|
@ -286,3 +286,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)
|
||||
}
|
||||
}
|
||||
|
|
110
sendmail.go
110
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"
|
||||
|
@ -31,12 +38,19 @@ var submitconf struct {
|
|||
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-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."`
|
||||
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")
|
||||
|
@ -322,6 +408,7 @@ binary should be setgid that group:
|
|||
opts := smtpclient.Opts{
|
||||
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
|
||||
}
|
||||
|
|
4
serve.go
4
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)
|
||||
|
|
|
@ -113,6 +113,7 @@ type Client struct {
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -22,7 +23,6 @@ import (
|
|||
"net"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"sort"
|
||||
|
@ -272,8 +272,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() {
|
||||
|
@ -320,7 +326,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
|
||||
|
@ -342,6 +348,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.
|
||||
|
||||
|
@ -385,17 +393,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{}
|
||||
if !c.authTLS {
|
||||
c.username = ""
|
||||
if c.account != nil {
|
||||
err := c.account.Close()
|
||||
c.log.Check(err, "closing account")
|
||||
}
|
||||
c.account = nil
|
||||
}
|
||||
c.authSASL = false
|
||||
c.rset()
|
||||
}
|
||||
|
||||
|
@ -593,7 +792,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
|
||||
|
@ -613,11 +812,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,
|
||||
|
@ -643,8 +842,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()
|
||||
|
@ -652,7 +851,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() {
|
||||
|
@ -677,6 +876,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
|
||||
|
@ -905,7 +1110,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 {
|
||||
|
@ -914,6 +1119,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
|
||||
|
@ -921,10 +1127,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))
|
||||
|
@ -949,7 +1157,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")
|
||||
}
|
||||
|
||||
|
@ -967,61 +1175,13 @@ 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")
|
||||
metricDeliveryStarttls.Inc()
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
// 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 := 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("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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ../rfc/4954:139
|
||||
func (c *conn) cmdAuth(p *parser) {
|
||||
c.xneedHello()
|
||||
|
@ -1029,7 +1189,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")
|
||||
}
|
||||
|
@ -1062,7 +1222,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)
|
||||
|
@ -1129,6 +1289,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"
|
||||
|
@ -1148,31 +1320,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):
|
||||
|
@ -1193,7 +1358,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
|
||||
|
@ -1205,7 +1370,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"
|
||||
|
@ -1214,14 +1380,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)
|
||||
|
||||
|
@ -1236,26 +1394,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 {
|
||||
|
@ -1270,8 +1423,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")
|
||||
}
|
||||
|
||||
|
@ -1280,19 +1433,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
|
||||
|
@ -1326,31 +1470,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")
|
||||
|
@ -1364,8 +1502,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
|
||||
|
@ -1384,14 +1522,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")
|
||||
|
@ -1401,19 +1539,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
|
||||
|
|
|
@ -90,6 +90,10 @@ type testserver struct {
|
|||
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
|
||||
|
@ -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()
|
||||
|
@ -182,6 +204,7 @@ func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
|
|||
opts := smtpclient.Opts{
|
||||
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)
|
||||
|
|
168
store/tlspubkey.go
Normal file
168
store/tlspubkey.go
Normal file
|
@ -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
|
||||
}
|
4
testdata/imap/domains.conf
vendored
4
testdata/imap/domains.conf
vendored
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -671,3 +672,80 @@ func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
|
|||
})
|
||||
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
|
||||
}
|
||||
|
|
|
@ -255,7 +255,7 @@ var api;
|
|||
// per-outgoing-message address used for sending.
|
||||
OutgoingEvent["EventUnrecognized"] = "unrecognized";
|
||||
})(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {}));
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true };
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true, "TLSPublicKey": true };
|
||||
api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true };
|
||||
api.intsTypes = {};
|
||||
api.types = {
|
||||
|
@ -280,6 +280,7 @@ var api;
|
|||
"NameAddress": { "Name": "NameAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "Address", "Docs": "", "Typewords": ["string"] }] },
|
||||
"Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] },
|
||||
"IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] },
|
||||
"TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"Localpart": { "Name": "Localpart", "Docs": "", "Values": null },
|
||||
"OutgoingEvent": { "Name": "OutgoingEvent", "Docs": "", "Values": [{ "Name": "EventDelivered", "Value": "delivered", "Docs": "" }, { "Name": "EventSuppressed", "Value": "suppressed", "Docs": "" }, { "Name": "EventDelayed", "Value": "delayed", "Docs": "" }, { "Name": "EventFailed", "Value": "failed", "Docs": "" }, { "Name": "EventRelayed", "Value": "relayed", "Docs": "" }, { "Name": "EventExpanded", "Value": "expanded", "Docs": "" }, { "Name": "EventCanceled", "Value": "canceled", "Docs": "" }, { "Name": "EventUnrecognized", "Value": "unrecognized", "Docs": "" }] },
|
||||
|
@ -306,6 +307,7 @@ var api;
|
|||
NameAddress: (v) => api.parse("NameAddress", v),
|
||||
Structure: (v) => api.parse("Structure", v),
|
||||
IncomingMeta: (v) => api.parse("IncomingMeta", v),
|
||||
TLSPublicKey: (v) => api.parse("TLSPublicKey", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
Localpart: (v) => api.parse("Localpart", v),
|
||||
OutgoingEvent: (v) => api.parse("OutgoingEvent", v),
|
||||
|
@ -525,6 +527,34 @@ var api;
|
|||
const params = [mailbox, keep];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeys() {
|
||||
const fn = "TLSPublicKeys";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["[]", "TLSPublicKey"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyAdd(loginAddress, name, noIMAPPreauth, certPEM) {
|
||||
const fn = "TLSPublicKeyAdd";
|
||||
const paramTypes = [["string"], ["string"], ["bool"], ["string"]];
|
||||
const returnTypes = [["TLSPublicKey"]];
|
||||
const params = [loginAddress, name, noIMAPPreauth, certPEM];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyRemove(fingerprint) {
|
||||
const fn = "TLSPublicKeyRemove";
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [fingerprint];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyUpdate(pubKey) {
|
||||
const fn = "TLSPublicKeyUpdate";
|
||||
const paramTypes = [["TLSPublicKey"]];
|
||||
const returnTypes = [];
|
||||
const params = [pubKey];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
api.defaultBaseURL = (function () {
|
||||
|
@ -1092,7 +1122,11 @@ const formatQuotaSize = (v) => {
|
|||
return '' + v;
|
||||
};
|
||||
const index = async () => {
|
||||
const [acc, storageUsed, storageLimit, suppressions] = await client.Account();
|
||||
const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([
|
||||
client.Account(),
|
||||
client.TLSPublicKeys(),
|
||||
]);
|
||||
const tlspubkeys = tlspubkeys0 || [];
|
||||
let fullNameForm;
|
||||
let fullNameFieldset;
|
||||
let fullName;
|
||||
|
@ -1431,7 +1465,104 @@ const index = async () => {
|
|||
}
|
||||
await check(passwordFieldset, client.SetPassword(password1.value));
|
||||
passwordForm.reset();
|
||||
}), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [
|
||||
}), dom.br(), dom.h2('TLS public keys'), dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), (() => {
|
||||
let elem = dom.div();
|
||||
const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.';
|
||||
const render = () => {
|
||||
const e = dom.div(dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title(preauthHelp)), dom.th('Fingerprint'), dom.th('Update'), dom.th('Remove'))), dom.tbody(tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [], tlspubkeys.map((tpk, index) => {
|
||||
let loginAddress;
|
||||
let name;
|
||||
let noIMAPPreauth;
|
||||
let update;
|
||||
const formID = 'tlk-' + index;
|
||||
const row = dom.tr(dom.td(dom.form(attr.id(formID), async function submit(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const ntpk = { ...tpk };
|
||||
ntpk.LoginAddress = loginAddress.value;
|
||||
ntpk.Name = name.value;
|
||||
ntpk.NoIMAPPreauth = noIMAPPreauth.checked;
|
||||
await check(update, client.TLSPublicKeyUpdate(ntpk));
|
||||
tpk.LoginAddress = ntpk.LoginAddress;
|
||||
tpk.Name = ntpk.Name;
|
||||
tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth;
|
||||
}, loginAddress = dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')))), dom.td(name = dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))), dom.td(tpk.Type), dom.td(dom.label(noIMAPPreauth = dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))), dom.td(tpk.Fingerprint), dom.td(update = dom.submitbutton(attr.form(formID), 'Update')), dom.td(dom.form(async function submit(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint));
|
||||
tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1);
|
||||
render();
|
||||
}, dom.submitbutton('Remove'))));
|
||||
return row;
|
||||
}))), dom.clickbutton('Add', style({ marginTop: '1ex' }), function click() {
|
||||
let address;
|
||||
let name;
|
||||
let noIMAPPreauth;
|
||||
let file;
|
||||
const close = popup(dom.div(style({ maxWidth: '45em' }), dom.h1('Add TLS public key'), dom.form(async function submit(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (file.files?.length !== 1) {
|
||||
throw new Error('exactly 1 certificate required'); // xxx
|
||||
}
|
||||
const certPEM = await new Promise((resolve, reject) => {
|
||||
const fr = new window.FileReader();
|
||||
fr.addEventListener('load', () => {
|
||||
resolve(fr.result);
|
||||
});
|
||||
fr.addEventListener('error', () => {
|
||||
reject(fr.error);
|
||||
});
|
||||
fr.readAsText(file.files[0]);
|
||||
});
|
||||
const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM));
|
||||
tlspubkeys.push(ntpk);
|
||||
render();
|
||||
close();
|
||||
}, dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Login address')), address = dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Login address used for sessions using this key.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), noIMAPPreauth = dom.input(attr.type('checkbox')), ' No IMAP "preauth"', attr.title(preauthHelp)), dom.div(style({ display: 'block', marginBottom: '1ex' }), dom.label(dom.div(dom.b('Certificate')), file = dom.input(attr.type('file'), attr.required(''))), dom.p(style({ fontStyle: 'italic', margin: '1ex 0' }), 'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ', dom.a('Show suggested commands', attr.href(''), function click(e) {
|
||||
e.preventDefault();
|
||||
popup(dom.h1('Generate a private key and certificate'), dom.pre(dom._class('literal'), `export keyname=... # Used for file names, certificate "common name" and as name of tls public key.
|
||||
# Suggestion: Use an application name and/or email address.
|
||||
export passphrase=... # Protects the private key in the PEM and p12 files.
|
||||
|
||||
# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate
|
||||
# for the corresponding public key.
|
||||
openssl req \\
|
||||
-config /dev/null \\
|
||||
-x509 \\
|
||||
-newkey ec \\
|
||||
-pkeyopt ec_paramgen_curve:P-256 \\
|
||||
-passout env:passphrase \\
|
||||
-keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-out "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-days 36500 \\
|
||||
-subj "/CN=$keyname"
|
||||
|
||||
# Generate a p12 file containing both certificate and private key, for
|
||||
# applications/operating systems that cannot read PEM files with
|
||||
# certificates/private keys.
|
||||
openssl pkcs12 \\
|
||||
-export \\
|
||||
-in "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-name "$keyname" \\
|
||||
-passin env:passphrase \\
|
||||
-passout env:passphrase \\
|
||||
-out "$keyname.ecdsa-p256-privatekey-certificate.p12"
|
||||
|
||||
# If the p12 file cannot be imported in the destination OS or email application,
|
||||
# try adding -legacy to the "openssl pkcs12" command.
|
||||
`));
|
||||
}), ' for generating a private key and certificate.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Name')), name = dom.input(), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Optional. If empty, the "subject common name" from the certificate is used.')), dom.br(), dom.submitbutton('Add'))));
|
||||
}));
|
||||
if (elem) {
|
||||
elem.replaceWith(e);
|
||||
}
|
||||
elem = e;
|
||||
};
|
||||
render();
|
||||
return elem;
|
||||
})(), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [
|
||||
dom.b('/', formatQuotaSize(storageLimit)),
|
||||
' (',
|
||||
'' + Math.floor(100 * storageUsed / storageLimit),
|
||||
|
|
|
@ -298,7 +298,11 @@ const formatQuotaSize = (v: number) => {
|
|||
}
|
||||
|
||||
const index = async () => {
|
||||
const [acc, storageUsed, storageLimit, suppressions] = await client.Account()
|
||||
const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([
|
||||
client.Account(),
|
||||
client.TLSPublicKeys(),
|
||||
])
|
||||
const tlspubkeys = tlspubkeys0 || []
|
||||
|
||||
let fullNameForm: HTMLFormElement
|
||||
let fullNameFieldset: HTMLFieldSetElement
|
||||
|
@ -872,6 +876,199 @@ const index = async () => {
|
|||
),
|
||||
dom.br(),
|
||||
|
||||
dom.h2('TLS public keys'),
|
||||
dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'),
|
||||
(() => {
|
||||
let elem = dom.div()
|
||||
|
||||
const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.'
|
||||
|
||||
const render = () => {
|
||||
const e = dom.div(
|
||||
dom.table(
|
||||
dom.thead(
|
||||
dom.tr(
|
||||
dom.th('Login address'),
|
||||
dom.th('Name'),
|
||||
dom.th('Type'),
|
||||
dom.th('No IMAP "preauth"', attr.title(preauthHelp)),
|
||||
dom.th('Fingerprint'),
|
||||
dom.th('Update'),
|
||||
dom.th('Remove'),
|
||||
),
|
||||
),
|
||||
dom.tbody(
|
||||
tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [],
|
||||
tlspubkeys.map((tpk, index) => {
|
||||
let loginAddress: HTMLInputElement
|
||||
let name: HTMLInputElement
|
||||
let noIMAPPreauth: HTMLInputElement
|
||||
let update: HTMLButtonElement
|
||||
|
||||
const formID = 'tlk-'+index
|
||||
const row = dom.tr(
|
||||
dom.td(
|
||||
dom.form(
|
||||
attr.id(formID),
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const ntpk: api.TLSPublicKey = {...tpk}
|
||||
ntpk.LoginAddress = loginAddress.value
|
||||
ntpk.Name = name.value
|
||||
ntpk.NoIMAPPreauth = noIMAPPreauth.checked
|
||||
await check(update, client.TLSPublicKeyUpdate(ntpk))
|
||||
tpk.LoginAddress = ntpk.LoginAddress
|
||||
tpk.Name = ntpk.Name
|
||||
tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth
|
||||
},
|
||||
loginAddress=dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')),
|
||||
),
|
||||
),
|
||||
dom.td(name=dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))),
|
||||
dom.td(tpk.Type),
|
||||
dom.td(dom.label(noIMAPPreauth=dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))),
|
||||
dom.td(tpk.Fingerprint),
|
||||
dom.td(update=dom.submitbutton(attr.form(formID), 'Update')),
|
||||
dom.td(
|
||||
dom.form(
|
||||
async function submit(e: SubmitEvent & {target: {disabled: boolean}}) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint))
|
||||
tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1)
|
||||
render()
|
||||
},
|
||||
dom.submitbutton('Remove'),
|
||||
),
|
||||
),
|
||||
)
|
||||
return row
|
||||
}),
|
||||
),
|
||||
),
|
||||
dom.clickbutton('Add', style({marginTop: '1ex'}), function click() {
|
||||
let address: HTMLInputElement
|
||||
let name: HTMLInputElement
|
||||
let noIMAPPreauth: HTMLInputElement
|
||||
let file: HTMLInputElement
|
||||
|
||||
const close = popup(
|
||||
dom.div(
|
||||
style({maxWidth: '45em'}),
|
||||
dom.h1('Add TLS public key'),
|
||||
dom.form(
|
||||
async function submit(e: SubmitEvent & {target: {disabled: boolean}}) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (file.files?.length !== 1) {
|
||||
throw new Error('exactly 1 certificate required') // xxx
|
||||
}
|
||||
const certPEM = await new Promise<string>((resolve, reject) => {
|
||||
const fr = new window.FileReader()
|
||||
fr.addEventListener('load', () => {
|
||||
resolve(fr.result as string)
|
||||
})
|
||||
fr.addEventListener('error', () => {
|
||||
reject(fr.error)
|
||||
})
|
||||
fr.readAsText(file.files![0])
|
||||
})
|
||||
const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM))
|
||||
tlspubkeys.push(ntpk)
|
||||
render()
|
||||
close()
|
||||
},
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.div(dom.b('Login address')),
|
||||
address=dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')),
|
||||
dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Login address used for sessions using this key.'),
|
||||
),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
noIMAPPreauth=dom.input(attr.type('checkbox')),
|
||||
' No IMAP "preauth"',
|
||||
attr.title(preauthHelp),
|
||||
),
|
||||
dom.div(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.label(
|
||||
dom.div(dom.b('Certificate')),
|
||||
file=dom.input(attr.type('file'), attr.required('')),
|
||||
),
|
||||
dom.p(
|
||||
style({fontStyle: 'italic', margin: '1ex 0'}),
|
||||
'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ',
|
||||
dom.a('Show suggested commands', attr.href(''), function click(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
popup(
|
||||
dom.h1('Generate a private key and certificate'),
|
||||
dom.pre(
|
||||
dom._class('literal'),
|
||||
`export keyname=... # Used for file names, certificate "common name" and as name of tls public key.
|
||||
# Suggestion: Use an application name and/or email address.
|
||||
export passphrase=... # Protects the private key in the PEM and p12 files.
|
||||
|
||||
# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate
|
||||
# for the corresponding public key.
|
||||
openssl req \\
|
||||
-config /dev/null \\
|
||||
-x509 \\
|
||||
-newkey ec \\
|
||||
-pkeyopt ec_paramgen_curve:P-256 \\
|
||||
-passout env:passphrase \\
|
||||
-keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-out "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-days 36500 \\
|
||||
-subj "/CN=$keyname"
|
||||
|
||||
# Generate a p12 file containing both certificate and private key, for
|
||||
# applications/operating systems that cannot read PEM files with
|
||||
# certificates/private keys.
|
||||
openssl pkcs12 \\
|
||||
-export \\
|
||||
-in "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-name "$keyname" \\
|
||||
-passin env:passphrase \\
|
||||
-passout env:passphrase \\
|
||||
-out "$keyname.ecdsa-p256-privatekey-certificate.p12"
|
||||
|
||||
# If the p12 file cannot be imported in the destination OS or email application,
|
||||
# try adding -legacy to the "openssl pkcs12" command.
|
||||
`
|
||||
),
|
||||
)
|
||||
}),
|
||||
' for generating a private key and certificate.',
|
||||
),
|
||||
),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.div(dom.b('Name')),
|
||||
name=dom.input(),
|
||||
dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Optional. If empty, the "subject common name" from the certificate is used.'),
|
||||
),
|
||||
dom.br(),
|
||||
dom.submitbutton('Add'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
if (elem) {
|
||||
elem.replaceWith(e)
|
||||
}
|
||||
elem = e
|
||||
}
|
||||
render()
|
||||
return elem
|
||||
})(),
|
||||
dom.br(),
|
||||
|
||||
dom.h2('Disk usage'),
|
||||
dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed/(1024*1024))*1024*1024)),
|
||||
storageLimit > 0 ? [
|
||||
|
|
|
@ -6,9 +6,14 @@ import (
|
|||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
@ -484,6 +489,65 @@ func TestAccount(t *testing.T) {
|
|||
api.RejectsSave(ctx, "Rejects", false)
|
||||
api.RejectsSave(ctx, "", false) // Restore.
|
||||
|
||||
// Make cert for TLSPublicKey.
|
||||
certBuf := fakeCert(t)
|
||||
var b bytes.Buffer
|
||||
err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf})
|
||||
tcheck(t, err, "encoding certificate as pem")
|
||||
certPEM := b.String()
|
||||
|
||||
err = store.Init(ctx)
|
||||
tcheck(t, err, "store init")
|
||||
defer func() {
|
||||
err := store.Close()
|
||||
tcheck(t, err, "store close")
|
||||
}()
|
||||
|
||||
tpkl, err := api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 0)
|
||||
|
||||
tpk, err := api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM)
|
||||
tcheck(t, err, "add tls public key")
|
||||
// Key already exists.
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, tpkl, []store.TLSPublicKey{tpk})
|
||||
|
||||
tpk.NoIMAPPreauth = true
|
||||
err = api.TLSPublicKeyUpdate(ctx, tpk)
|
||||
tcheck(t, err, "tls public key update")
|
||||
badtpk := tpk
|
||||
badtpk.Fingerprint = "bogus"
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyUpdate(ctx, badtpk) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 1)
|
||||
tcompare(t, tpkl[0].NoIMAPPreauth, true)
|
||||
|
||||
err = api.TLSPublicKeyRemove(ctx, tpk.Fingerprint)
|
||||
tcheck(t, err, "tls public key remove")
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyRemove(ctx, tpk.Fingerprint) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 0)
|
||||
|
||||
api.Logout(ctx)
|
||||
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -450,6 +450,84 @@
|
|||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeys",
|
||||
"Docs": "",
|
||||
"Params": [],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyAdd",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "loginAddress",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "name",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "noIMAPPreauth",
|
||||
"Typewords": [
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "certPEM",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyRemove",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "fingerprint",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyUpdate",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "pubKey",
|
||||
"Typewords": [
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
}
|
||||
],
|
||||
"Sections": [],
|
||||
|
@ -1510,6 +1588,69 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKey",
|
||||
"Docs": "TLSPublicKey is a public key for use with TLS client authentication based on the\npublic key of the certificate.",
|
||||
"Fields": [
|
||||
{
|
||||
"Name": "Fingerprint",
|
||||
"Docs": "Raw-url-base64-encoded Subject Public Key Info of certificate.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Created",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"timestamp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Type",
|
||||
"Docs": "E.g. \"rsa-2048\", \"ecdsa-p256\", \"ed25519\"",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Name",
|
||||
"Docs": "Descriptive name to identify the key, e.g. the device where key is used.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "NoIMAPPreauth",
|
||||
"Docs": "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.",
|
||||
"Typewords": [
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "CertDER",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"uint8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Account",
|
||||
"Docs": "Key authenticates this account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "LoginAddress",
|
||||
"Docs": "Must belong to account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Ints": [],
|
||||
|
|
|
@ -204,6 +204,19 @@ export interface IncomingMeta {
|
|||
Automated: boolean // Whether this message was automated and should not receive automated replies. E.g. out of office or mailing list messages.
|
||||
}
|
||||
|
||||
// TLSPublicKey is a public key for use with TLS client authentication based on the
|
||||
// public key of the certificate.
|
||||
export interface TLSPublicKey {
|
||||
Fingerprint: string // Raw-url-base64-encoded Subject Public Key Info of certificate.
|
||||
Created: Date
|
||||
Type: string // E.g. "rsa-2048", "ecdsa-p256", "ed25519"
|
||||
Name: string // Descriptive name to identify the key, e.g. the device where key is used.
|
||||
NoIMAPPreauth: boolean // 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.
|
||||
CertDER?: string | null
|
||||
Account: string // Key authenticates this account.
|
||||
LoginAddress: string // Must belong to account.
|
||||
}
|
||||
|
||||
export type CSRFToken = string
|
||||
|
||||
// Localpart is a decoded local part of an email address, before the "@".
|
||||
|
@ -238,7 +251,7 @@ export enum OutgoingEvent {
|
|||
EventUnrecognized = "unrecognized",
|
||||
}
|
||||
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true}
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true,"TLSPublicKey":true}
|
||||
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true}
|
||||
export const intsTypes: {[typename: string]: boolean} = {}
|
||||
export const types: TypenameMap = {
|
||||
|
@ -263,6 +276,7 @@ export const types: TypenameMap = {
|
|||
"NameAddress": {"Name":"NameAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"Address","Docs":"","Typewords":["string"]}]},
|
||||
"Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]},
|
||||
"IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]},
|
||||
"TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]},
|
||||
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
|
||||
"Localpart": {"Name":"Localpart","Docs":"","Values":null},
|
||||
"OutgoingEvent": {"Name":"OutgoingEvent","Docs":"","Values":[{"Name":"EventDelivered","Value":"delivered","Docs":""},{"Name":"EventSuppressed","Value":"suppressed","Docs":""},{"Name":"EventDelayed","Value":"delayed","Docs":""},{"Name":"EventFailed","Value":"failed","Docs":""},{"Name":"EventRelayed","Value":"relayed","Docs":""},{"Name":"EventExpanded","Value":"expanded","Docs":""},{"Name":"EventCanceled","Value":"canceled","Docs":""},{"Name":"EventUnrecognized","Value":"unrecognized","Docs":""}]},
|
||||
|
@ -290,6 +304,7 @@ export const parser = {
|
|||
NameAddress: (v: any) => parse("NameAddress", v) as NameAddress,
|
||||
Structure: (v: any) => parse("Structure", v) as Structure,
|
||||
IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta,
|
||||
TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey,
|
||||
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
|
||||
Localpart: (v: any) => parse("Localpart", v) as Localpart,
|
||||
OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent,
|
||||
|
@ -535,6 +550,38 @@ export class Client {
|
|||
const params: any[] = [mailbox, keep]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
async TLSPublicKeys(): Promise<TLSPublicKey[] | null> {
|
||||
const fn: string = "TLSPublicKeys"
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["[]","TLSPublicKey"]]
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null
|
||||
}
|
||||
|
||||
async TLSPublicKeyAdd(loginAddress: string, name: string, noIMAPPreauth: boolean, certPEM: string): Promise<TLSPublicKey> {
|
||||
const fn: string = "TLSPublicKeyAdd"
|
||||
const paramTypes: string[][] = [["string"],["string"],["bool"],["string"]]
|
||||
const returnTypes: string[][] = [["TLSPublicKey"]]
|
||||
const params: any[] = [loginAddress, name, noIMAPPreauth, certPEM]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey
|
||||
}
|
||||
|
||||
async TLSPublicKeyRemove(fingerprint: string): Promise<void> {
|
||||
const fn: string = "TLSPublicKeyRemove"
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [fingerprint]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
async TLSPublicKeyUpdate(pubKey: TLSPublicKey): Promise<void> {
|
||||
const fn: string = "TLSPublicKeyUpdate"
|
||||
const paramTypes: string[][] = [["TLSPublicKey"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [pubKey]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultBaseURL = (function() {
|
||||
|
|
|
@ -2682,3 +2682,7 @@ func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainNam
|
|||
err := admin.AliasAddressesRemove(ctx, addr, addresses)
|
||||
xcheckf(ctx, err, "removing address from alias")
|
||||
}
|
||||
|
||||
func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) {
|
||||
return store.TLSPublicKeyList(ctx, accountOpt)
|
||||
}
|
||||
|
|
|
@ -250,7 +250,7 @@ var api;
|
|||
Mode["ModeTesting"] = "testing";
|
||||
Mode["ModeNone"] = "none";
|
||||
})(Mode = api.Mode || (api.Mode = {}));
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSPublicKey": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
|
||||
api.stringsTypes = { "Align": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true };
|
||||
api.intsTypes = {};
|
||||
api.types = {
|
||||
|
@ -363,6 +363,7 @@ var api;
|
|||
"TLSResult": { "Name": "TLSResult", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "PolicyDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "DayUTC", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Updated", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "IsHost", "Docs": "", "Typewords": ["bool"] }, { "Name": "SendReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "SentToRecipientDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecipientDomainReportingAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "SentToPolicyDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "Results", "Docs": "", "Typewords": ["[]", "Result"] }] },
|
||||
"TLSRPTSuppressAddress": { "Name": "TLSRPTSuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] },
|
||||
"Dynamic": { "Name": "Dynamic", "Docs": "", "Fields": [{ "Name": "Domains", "Docs": "", "Typewords": ["{}", "ConfigDomain"] }, { "Name": "Accounts", "Docs": "", "Typewords": ["{}", "Account"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "MonitorDNSBLs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MonitorDNSBLZones", "Docs": "", "Typewords": ["[]", "Domain"] }] },
|
||||
"TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"DMARCPolicy": { "Name": "DMARCPolicy", "Docs": "", "Values": [{ "Name": "PolicyEmpty", "Value": "", "Docs": "" }, { "Name": "PolicyNone", "Value": "none", "Docs": "" }, { "Name": "PolicyQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "PolicyReject", "Value": "reject", "Docs": "" }] },
|
||||
"Align": { "Name": "Align", "Docs": "", "Values": [{ "Name": "AlignStrict", "Value": "s", "Docs": "" }, { "Name": "AlignRelaxed", "Value": "r", "Docs": "" }] },
|
||||
|
@ -481,6 +482,7 @@ var api;
|
|||
TLSResult: (v) => api.parse("TLSResult", v),
|
||||
TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v),
|
||||
Dynamic: (v) => api.parse("Dynamic", v),
|
||||
TLSPublicKey: (v) => api.parse("TLSPublicKey", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
DMARCPolicy: (v) => api.parse("DMARCPolicy", v),
|
||||
Align: (v) => api.parse("Align", v),
|
||||
|
@ -1291,6 +1293,13 @@ var api;
|
|||
const params = [aliaslp, domainName, addresses];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeys(accountOpt) {
|
||||
const fn = "TLSPublicKeys";
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["[]", "TLSPublicKey"]];
|
||||
const params = [accountOpt];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
api.defaultBaseURL = (function () {
|
||||
|
@ -2091,10 +2100,11 @@ const RoutesEditor = (kind, transports, routes, save) => {
|
|||
return render();
|
||||
};
|
||||
const account = async (name) => {
|
||||
const [[config, diskUsage], domains, transports] = await Promise.all([
|
||||
const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([
|
||||
client.Account(name),
|
||||
client.Domains(),
|
||||
client.Transports(),
|
||||
client.TLSPublicKeys(name),
|
||||
]);
|
||||
// todo: show suppression list, and buttons to add/remove entries.
|
||||
let form;
|
||||
|
@ -2194,7 +2204,10 @@ const account = async (name) => {
|
|||
await check(fieldsetPassword, client.SetPassword(name, password.value));
|
||||
window.alert('Password has been changed.');
|
||||
formPassword.reset();
|
||||
}), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) {
|
||||
}), dom.br(), dom.h2('TLS public keys', attr.title('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.')), dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title('New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.')), dom.th('Fingerprint'))), dom.tbody(tlspubkeys?.length ? [] : dom.tr(dom.td(attr.colspan('5'), 'None')), (tlspubkeys || []).map(tpk => {
|
||||
const row = dom.tr(dom.td(tpk.LoginAddress), dom.td(tpk.Name), dom.td(tpk.Type), dom.td(tpk.NoIMAPPreauth ? 'Enabled' : ''), dom.td(tpk.Fingerprint));
|
||||
return row;
|
||||
}))), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) {
|
||||
return;
|
||||
|
|
|
@ -759,10 +759,11 @@ const RoutesEditor = (kind: string, transports: { [key: string]: api.Transport }
|
|||
}
|
||||
|
||||
const account = async (name: string) => {
|
||||
const [[config, diskUsage], domains, transports] = await Promise.all([
|
||||
const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([
|
||||
client.Account(name),
|
||||
client.Domains(),
|
||||
client.Transports(),
|
||||
client.TLSPublicKeys(name),
|
||||
])
|
||||
|
||||
// todo: show suppression list, and buttons to add/remove entries.
|
||||
|
@ -994,6 +995,33 @@ const account = async (name: string) => {
|
|||
formPassword.reset()
|
||||
},
|
||||
),
|
||||
dom.br(),
|
||||
dom.h2('TLS public keys', attr.title('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.')),
|
||||
dom.table(
|
||||
dom.thead(
|
||||
dom.tr(
|
||||
dom.th('Login address'),
|
||||
dom.th('Name'),
|
||||
dom.th('Type'),
|
||||
dom.th('No IMAP "preauth"', attr.title('New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.')),
|
||||
dom.th('Fingerprint'),
|
||||
),
|
||||
),
|
||||
dom.tbody(
|
||||
tlspubkeys?.length ? [] : dom.tr(dom.td(attr.colspan('5'), 'None')),
|
||||
(tlspubkeys || []).map(tpk => {
|
||||
const row = dom.tr(
|
||||
dom.td(tpk.LoginAddress),
|
||||
dom.td(tpk.Name),
|
||||
dom.td(tpk.Type),
|
||||
dom.td(tpk.NoIMAPPreauth ? 'Enabled' : ''),
|
||||
dom.td(tpk.Fingerprint),
|
||||
)
|
||||
return row
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
dom.br(),
|
||||
RoutesEditor('account-specific', transports, config.Routes || [], async (routes: api.Route[]) => await client.AccountRoutesSave(name, routes)),
|
||||
dom.br(),
|
||||
|
|
|
@ -2038,6 +2038,27 @@
|
|||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeys",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "accountOpt",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Sections": [],
|
||||
|
@ -7239,6 +7260,69 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKey",
|
||||
"Docs": "TLSPublicKey is a public key for use with TLS client authentication based on the\npublic key of the certificate.",
|
||||
"Fields": [
|
||||
{
|
||||
"Name": "Fingerprint",
|
||||
"Docs": "Raw-url-base64-encoded Subject Public Key Info of certificate.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Created",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"timestamp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Type",
|
||||
"Docs": "E.g. \"rsa-2048\", \"ecdsa-p256\", \"ed25519\"",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Name",
|
||||
"Docs": "Descriptive name to identify the key, e.g. the device where key is used.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "NoIMAPPreauth",
|
||||
"Docs": "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.",
|
||||
"Typewords": [
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "CertDER",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"uint8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Account",
|
||||
"Docs": "Key authenticates this account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "LoginAddress",
|
||||
"Docs": "Must belong to account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Ints": [],
|
||||
|
|
|
@ -1052,6 +1052,19 @@ export interface Dynamic {
|
|||
MonitorDNSBLZones?: Domain[] | null
|
||||
}
|
||||
|
||||
// TLSPublicKey is a public key for use with TLS client authentication based on the
|
||||
// public key of the certificate.
|
||||
export interface TLSPublicKey {
|
||||
Fingerprint: string // Raw-url-base64-encoded Subject Public Key Info of certificate.
|
||||
Created: Date
|
||||
Type: string // E.g. "rsa-2048", "ecdsa-p256", "ed25519"
|
||||
Name: string // Descriptive name to identify the key, e.g. the device where key is used.
|
||||
NoIMAPPreauth: boolean // 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.
|
||||
CertDER?: string | null
|
||||
Account: string // Key authenticates this account.
|
||||
LoginAddress: string // Must belong to account.
|
||||
}
|
||||
|
||||
export type CSRFToken = string
|
||||
|
||||
// Policy as used in DMARC DNS record for "p=" or "sp=".
|
||||
|
@ -1096,7 +1109,7 @@ export type Localpart = string
|
|||
// be an IPv4 address.
|
||||
export type IP = string
|
||||
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSPublicKey":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
|
||||
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true}
|
||||
export const intsTypes: {[typename: string]: boolean} = {}
|
||||
export const types: TypenameMap = {
|
||||
|
@ -1209,6 +1222,7 @@ export const types: TypenameMap = {
|
|||
"TLSResult": {"Name":"TLSResult","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"PolicyDomain","Docs":"","Typewords":["string"]},{"Name":"DayUTC","Docs":"","Typewords":["string"]},{"Name":"RecipientDomain","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Updated","Docs":"","Typewords":["timestamp"]},{"Name":"IsHost","Docs":"","Typewords":["bool"]},{"Name":"SendReport","Docs":"","Typewords":["bool"]},{"Name":"SentToRecipientDomain","Docs":"","Typewords":["bool"]},{"Name":"RecipientDomainReportingAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"SentToPolicyDomain","Docs":"","Typewords":["bool"]},{"Name":"Results","Docs":"","Typewords":["[]","Result"]}]},
|
||||
"TLSRPTSuppressAddress": {"Name":"TLSRPTSuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]},
|
||||
"Dynamic": {"Name":"Dynamic","Docs":"","Fields":[{"Name":"Domains","Docs":"","Typewords":["{}","ConfigDomain"]},{"Name":"Accounts","Docs":"","Typewords":["{}","Account"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["{}","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"MonitorDNSBLs","Docs":"","Typewords":["[]","string"]},{"Name":"MonitorDNSBLZones","Docs":"","Typewords":["[]","Domain"]}]},
|
||||
"TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]},
|
||||
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
|
||||
"DMARCPolicy": {"Name":"DMARCPolicy","Docs":"","Values":[{"Name":"PolicyEmpty","Value":"","Docs":""},{"Name":"PolicyNone","Value":"none","Docs":""},{"Name":"PolicyQuarantine","Value":"quarantine","Docs":""},{"Name":"PolicyReject","Value":"reject","Docs":""}]},
|
||||
"Align": {"Name":"Align","Docs":"","Values":[{"Name":"AlignStrict","Value":"s","Docs":""},{"Name":"AlignRelaxed","Value":"r","Docs":""}]},
|
||||
|
@ -1328,6 +1342,7 @@ export const parser = {
|
|||
TLSResult: (v: any) => parse("TLSResult", v) as TLSResult,
|
||||
TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress,
|
||||
Dynamic: (v: any) => parse("Dynamic", v) as Dynamic,
|
||||
TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey,
|
||||
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
|
||||
DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy,
|
||||
Align: (v: any) => parse("Align", v) as Align,
|
||||
|
@ -2235,6 +2250,14 @@ export class Client {
|
|||
const params: any[] = [aliaslp, domainName, addresses]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
async TLSPublicKeys(accountOpt: string): Promise<TLSPublicKey[] | null> {
|
||||
const fn: string = "TLSPublicKeys"
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = [["[]","TLSPublicKey"]]
|
||||
const params: any[] = [accountOpt]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultBaseURL = (function() {
|
||||
|
|
Loading…
Reference in a new issue