mox/webaccount/account_test.go
Mechiel Lukkien 8804d6b60e
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.
2024-12-06 10:08:17 +01:00

553 lines
18 KiB
Go

package webaccount
import (
"archive/tar"
"archive/zip"
"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"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"runtime/debug"
"sort"
"strings"
"testing"
"time"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
"github.com/mjl-/mox/webhook"
)
var ctxbg = context.Background()
func init() {
mox.LimitersInit()
webauth.BadAuthDelay = 0
}
func tcheck(t *testing.T, err error, msg string) {
t.Helper()
if err != nil {
t.Fatalf("%s: %s", msg, err)
}
}
func readBody(r io.Reader) string {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Sprintf("read error: %s", err)
}
return fmt.Sprintf("data: %q", buf)
}
func tneedErrorCode(t *testing.T, code string, fn func()) {
t.Helper()
defer func() {
t.Helper()
x := recover()
if x == nil {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw success")
}
if err, ok := x.(*sherpa.Error); !ok {
debug.PrintStack()
t.Fatalf("expected sherpa error, saw %#v", x)
} else if err.Code != code {
debug.PrintStack()
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
}
}()
fn()
}
func tcompare(t *testing.T, got, expect any) {
t.Helper()
if !reflect.DeepEqual(got, expect) {
t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
}
}
func TestAccount(t *testing.T) {
os.RemoveAll("../testdata/httpaccount/data")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false)
log := mlog.New("webaccount", nil)
acc, err := store.OpenAccount(log, "mjl☺")
tcheck(t, err, "open account")
err = acc.SetPassword(log, "test1234")
tcheck(t, err, "set password")
defer func() {
err = acc.Close()
tcheck(t, err, "closing account")
acc.CheckClosed()
}()
defer store.Switchboard()()
api := Account{cookiePath: "/account/"}
apiHandler, err := makeSherpaHandler(api.cookiePath, false)
tcheck(t, err, "sherpa handler")
// Record HTTP response to get session cookie for login.
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Missing login token.
tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl☺@mox.example", "test1234") })
// Login with loginToken.
loginCookie := &http.Cookie{Name: "webaccountlogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
csrfToken := api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "test1234")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webaccountsession" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
// Valid loginToken, but bad credentials.
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
type httpHeaders [][2]string
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
for _, kv := range headers {
req.Header.Add(kv[0], kv[1])
}
rr := httptest.NewRecorder()
rr.Body = &bytes.Buffer{}
handle(apiHandler, false, rr, req)
if rr.Code != expStatusCode {
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
}
resp := rr.Result()
for _, h := range expHeaders {
if resp.Header.Get(h[0]) != h[1] {
t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
}
}
if check != nil {
check(resp)
}
}
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
}
userAuthError := func(resp *http.Response, expCode string) {
t.Helper()
var response struct {
Error *sherpa.Error `json:"error"`
}
err := json.NewDecoder(resp.Body).Decode(&response)
tcheck(t, err, "parsing response as json")
if response.Error == nil {
t.Fatalf("expected sherpa error with code %s, no error", expCode)
}
if response.Error.Code != expCode {
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
}
}
badAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:badAuth")
}
noAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:noAuth")
}
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
// SetPassword needs the token.
sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
reqInfo = requestInfo{"mjl☺@mox.example", "mjl☺", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.SetPassword(ctx, "test1234")
err = queue.Init() // For DB.
tcheck(t, err, "queue init")
defer queue.Shutdown()
account, _, _, _ := api.Account(ctx)
// Check we don't see the alias member list.
tcompare(t, len(account.Aliases), 1)
tcompare(t, account.Aliases[0], config.AddressAlias{
SubscriptionAddress: "mjl☺@mox.example",
Alias: config.Alias{
LocalpartStr: "support",
Domain: dns.Domain{ASCII: "mox.example"},
AllowMsgFrom: true,
},
})
api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed
api.AccountSaveFullName(ctx, account.FullName)
go ImportManage()
defer func() {
importers.Stop <- struct{}{}
}()
// Import mbox/maildir tgz/zip.
testImport := func(filename string, expect int) {
t.Helper()
var reqBody bytes.Buffer
mpw := multipart.NewWriter(&reqBody)
part, err := mpw.CreateFormFile("file", path.Base(filename))
tcheck(t, err, "creating form file")
buf, err := os.ReadFile(filename)
tcheck(t, err, "reading file")
_, err = part.Write(buf)
tcheck(t, err, "write part")
err = mpw.Close()
tcheck(t, err, "close multipart writer")
r := httptest.NewRequest("POST", "/import", &reqBody)
r.Header.Add("Content-Type", mpw.FormDataContentType())
r.Header.Add("x-mox-csrf", string(csrfToken))
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
var m ImportProgress
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("parsing import response: %v", err)
}
l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
importers.Register <- &l
if !<-l.Register {
t.Fatalf("register failed")
}
defer func() {
importers.Unregister <- &l
}()
count := 0
loop:
for {
e := <-l.Events
if e.Event == nil {
continue
}
switch x := e.Event.(type) {
case importCount:
count += x.Count
case importProblem:
t.Fatalf("unexpected problem: %q", x.Message)
case importStep:
case importDone:
break loop
case importAborted:
t.Fatalf("unexpected aborted import")
default:
panic(fmt.Sprintf("missing case for Event %#v", e))
}
}
if count != expect {
t.Fatalf("imported %d messages, expected %d", count, expect)
}
}
testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
// Check there are messages, with the right flags.
acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
_, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
tcheck(t, err, `fetching message with keywords "other" and "test"`)
mb, err := acc.MailboxFind(tx, "importtest")
tcheck(t, err, "looking up mailbox importtest")
if mb == nil {
t.Fatalf("missing mailbox importtest")
}
sort.Strings(mb.Keywords)
if strings.Join(mb.Keywords, " ") != "other test" {
t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
}
n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
tcheck(t, err, `fetching message with keyword "custom"`)
if n != 2 {
t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
}
mb, err = acc.MailboxFind(tx, "maildir")
tcheck(t, err, "looking up mailbox maildir")
if mb == nil {
t.Fatalf("missing mailbox maildir")
}
if strings.Join(mb.Keywords, " ") != "custom" {
t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
}
return nil
})
testExport := func(format, archive string, expectFiles int) {
t.Helper()
fields := url.Values{
"csrf": []string{string(csrfToken)},
"format": []string{format},
"archive": []string{archive},
"mailbox": []string{""},
"recursive": []string{"on"},
}
r := httptest.NewRequest("POST", "/export", strings.NewReader(fields.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
var count int
if archive == "zip" {
buf := w.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
tcheck(t, err, "reading zip")
for _, f := range zr.File {
if !strings.HasSuffix(f.Name, "/") {
count++
}
}
} else {
var src io.Reader = w.Body
if archive == "tgz" {
gzr, err := gzip.NewReader(src)
tcheck(t, err, "gzip reader")
src = gzr
}
tr := tar.NewReader(src)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
tcheck(t, err, "next file in tar")
if !strings.HasSuffix(h.Name, "/") {
count++
}
_, err = io.Copy(io.Discard, tr)
tcheck(t, err, "reading from tar")
}
}
if count != expectFiles {
t.Fatalf("export, has %d files, expected %d", count, expectFiles)
}
}
testExport("maildir", "tgz", 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
testExport("maildir", "zip", 6)
testExport("mbox", "tar", 2+6) // 2 imported plus 6 default mailboxes (Inbox, Draft, etc)
testExport("mbox", "zip", 2+6)
sl := api.SuppressionList(ctx)
tcompare(t, len(sl), 0)
api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing")
tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing") }) // Duplicate.
tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "bogus", true, "testing") }) // Bad address.
sl = api.SuppressionList(ctx)
tcompare(t, len(sl), 1)
api.SuppressionRemove(ctx, "mjl@mox.example")
tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "mjl@mox.example") }) // Absent.
tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "bogus") }) // Not an address.
var hooks int
hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
hooks++
}))
defer hookServer.Close()
api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{"delivered"})
api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{})
tneedErrorCode(t, "user:error", func() {
api.OutgoingWebhookSave(ctx, "http://localhost:1234/outgoing", "Basic base64", []string{"bogus"})
})
tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookSave(ctx, "invalid", "Basic base64", nil) })
api.OutgoingWebhookSave(ctx, "", "", nil) // Restore.
code, response, errmsg := api.OutgoingWebhookTest(ctx, hookServer.URL, "", webhook.Outgoing{})
tcompare(t, code, 200)
tcompare(t, response, "ok\n")
tcompare(t, errmsg, "")
tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookTest(ctx, "bogus", "", webhook.Outgoing{}) })
api.IncomingWebhookSave(ctx, "http://localhost:1234", "Basic base64")
tneedErrorCode(t, "user:error", func() { api.IncomingWebhookSave(ctx, "invalid", "Basic base64") })
api.IncomingWebhookSave(ctx, "", "") // Restore.
code, response, errmsg = api.IncomingWebhookTest(ctx, hookServer.URL, "", webhook.Incoming{})
tcompare(t, code, 200)
tcompare(t, response, "ok\n")
tcompare(t, errmsg, "")
tneedErrorCode(t, "user:error", func() { api.IncomingWebhookTest(ctx, "bogus", "", webhook.Incoming{}) })
api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example"})
api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example", "mjl☺+fromid@mox.example"})
api.FromIDLoginAddressesSave(ctx, []string{})
tneedErrorCode(t, "user:error", func() { api.FromIDLoginAddressesSave(ctx, []string{"bogus@other.example"}) })
api.KeepRetiredPeriodsSave(ctx, time.Minute, time.Minute)
api.KeepRetiredPeriodsSave(ctx, 0, 0) // Restore.
api.AutomaticJunkFlagsSave(ctx, true, "^(junk|spam)", "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)", "")
api.AutomaticJunkFlagsSave(ctx, false, "", "", "")
api.JunkFilterSave(ctx, nil)
jf := config.JunkFilter{
Threshold: 0.95,
Params: junk.Params{
Twograms: true,
MaxPower: 0.1,
TopWords: 10,
IgnoreWords: 0.1,
},
}
api.JunkFilterSave(ctx, &jf)
api.RejectsSave(ctx, "Rejects", true)
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
}