diff --git a/go.mod b/go.mod index 5a7b5d5..f47c99f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect @@ -30,9 +31,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect github.com/aws/smithy-go v1.25.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/boombuler/barcode v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect github.com/emersion/go-message v0.18.2 // indirect @@ -51,7 +54,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -59,6 +64,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect + go.mozilla.org/pkcs7 v0.10.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect @@ -78,4 +84,5 @@ require ( modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect ) diff --git a/go.sum b/go.sum index 2998e12..20c9175 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= @@ -34,6 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= @@ -42,6 +46,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -93,10 +99,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -125,6 +135,8 @@ github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77ro github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg= +go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= @@ -230,3 +242,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/abuseguard/abuseguard.go b/internal/abuseguard/abuseguard.go new file mode 100644 index 0000000..1b39a6e --- /dev/null +++ b/internal/abuseguard/abuseguard.go @@ -0,0 +1,96 @@ +// Package abuseguard automatically blacklists IPs that rack up too many failed +// SMTP/IMAP auth attempts, and rejects connections from already-blacklisted IPs before +// the SMTP/IMAP banner is ever sent. Deliberately separate from the web admin/webmail +// login lockout (internal/webui/ratelimit.go) and from the relay-authorization +// whitelist (esrv_whitelisted_ips) — see the [Security] section of settings.ini. +package abuseguard + +import ( + "net" + "time" + + "gopkg.in/ini.v1" + + "mailgoserver/internal/db" + "mailgoserver/internal/toolbox" +) + +// RecordFailureAndMaybeBlacklist should be called after every failed SMTP AUTH or IMAP +// login. It counts recent failures from ip and blacklists it once the configured +// threshold is hit. Fails open (does nothing) on a DB error rather than blocking auth +// over a transient issue. +func RecordFailureAndMaybeBlacklist(database *db.DB, cfg *ini.File, logger *toolbox.Logger, ip string) { + if ip == "" || cfg == nil { + return + } + sec := cfg.Section("Security") + if !sec.Key("abuse_detection_enabled").MustBool(true) { + return + } + if whitelisted, err := database.IsIPAbuseWhitelisted(ip); err != nil || whitelisted { + return + } + + threshold := sec.Key("abuse_failure_threshold").MustInt(8) + windowMinutes := sec.Key("abuse_detection_window_minutes").MustInt(10) + since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute) + + n, err := database.CountFailedAuthAttemptsByIP(ip, since) + if err != nil || n < threshold { + return + } + + baseHours := sec.Key("abuse_blacklist_base_hours").MustInt(12) + maxHours := sec.Key("abuse_blacklist_max_hours").MustInt(168) + reason := "automatic: too many failed SMTP/IMAP auth attempts" + if err := database.BlacklistIP(ip, reason, baseHours, maxHours); err != nil && logger != nil { + logger.Error("abuseguard: failed to blacklist %s: %v", ip, err) + return + } + if logger != nil { + logger.Warning("abuseguard: blacklisted %s after %d failed attempts in %dm", ip, n, windowMinutes) + } +} + +// guardedListener wraps a net.Listener so Accept() silently drops connections from +// blacklisted IPs (never returning them to the caller) before any protocol banner is +// written, and keeps looping rather than returning an error. +type guardedListener struct { + net.Listener + database *db.DB + logger *toolbox.Logger +} + +// GuardListener wraps inner so every accepted connection is checked against the IP +// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller +// ever sees it. +func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener { + return &guardedListener{Listener: inner, database: database, logger: logger} +} + +func (g *guardedListener) Accept() (net.Conn, error) { + for { + conn, err := g.Listener.Accept() + if err != nil { + return nil, err + } + host, _, splitErr := net.SplitHostPort(conn.RemoteAddr().String()) + if splitErr != nil { + host = conn.RemoteAddr().String() + } + if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted { + return conn, nil + } + blocked, bErr := g.database.IsIPBlacklisted(host) + if bErr != nil { + return conn, nil // fail open on a DB error + } + if !blocked { + return conn, nil + } + if g.logger != nil { + g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host) + } + conn.Close() + } +} diff --git a/internal/abuseguard/abuseguard_test.go b/internal/abuseguard/abuseguard_test.go new file mode 100644 index 0000000..f3eb715 --- /dev/null +++ b/internal/abuseguard/abuseguard_test.go @@ -0,0 +1,146 @@ +package abuseguard + +import ( + "net" + "path/filepath" + "strconv" + "testing" + + "gopkg.in/ini.v1" + + "mailgoserver/internal/db" +) + +func testCfg(t *testing.T, threshold int) *ini.File { + t.Helper() + cfg := ini.Empty() + sec, _ := cfg.NewSection("Security") + sec.NewKey("abuse_detection_enabled", "true") + sec.NewKey("abuse_failure_threshold", strconv.Itoa(threshold)) + sec.NewKey("abuse_detection_window_minutes", "10") + sec.NewKey("abuse_blacklist_base_hours", "12") + sec.NewKey("abuse_blacklist_max_hours", "168") + return cfg +} + +func openTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + return database +} + +func TestRecordFailureAndMaybeBlacklistTripsThreshold(t *testing.T) { + database := openTestDB(t) + cfg := testCfg(t, 3) + const ip = "203.0.113.50" + + for i := 0; i < 2; i++ { + database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") + RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) + } + if blocked, _ := database.IsIPBlacklisted(ip); blocked { + t.Fatal("should not be blacklisted before threshold") + } + + database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") + RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) + + blocked, err := database.IsIPBlacklisted(ip) + if err != nil { + t.Fatal(err) + } + if !blocked { + t.Fatal("expected IP to be blacklisted after hitting the threshold") + } +} + +func TestRecordFailureAndMaybeBlacklistSkipsWhitelisted(t *testing.T) { + database := openTestDB(t) + cfg := testCfg(t, 2) + const ip = "203.0.113.51" + if err := database.AddAbuseWhitelist(ip, "trusted"); err != nil { + t.Fatal(err) + } + + for i := 0; i < 5; i++ { + database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") + RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) + } + + if blocked, _ := database.IsIPBlacklisted(ip); blocked { + t.Fatal("whitelisted IP should never be blacklisted") + } +} + +// fakeListener yields exactly one already-open in-memory connection pair, then EOF-like +// closed errors, letting GuardListener's Accept loop be tested without real sockets. +type fakeListener struct { + conns chan net.Conn + done chan struct{} +} + +func newFakeListener(conns ...net.Conn) *fakeListener { + ch := make(chan net.Conn, len(conns)) + for _, c := range conns { + ch <- c + } + return &fakeListener{conns: ch, done: make(chan struct{})} +} + +func (f *fakeListener) Accept() (net.Conn, error) { + select { + case c := <-f.conns: + return c, nil + case <-f.done: + return nil, net.ErrClosed + } +} +func (f *fakeListener) Close() error { close(f.done); return nil } +func (f *fakeListener) Addr() net.Addr { return dummyAddr{} } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "tcp" } +func (dummyAddr) String() string { return "0.0.0.0:0" } + +func TestGuardListenerRejectsBlacklistedIP(t *testing.T) { + database := openTestDB(t) + const blockedIP = "198.51.100.77" + if err := database.AddManualBlacklistEntry(blockedIP, "test", 1); err != nil { + t.Fatal(err) + } + + blockedConn, blockedPeer := net.Pipe() + defer blockedPeer.Close() + + inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)}) + guarded := GuardListener(inner, database, nil) + + go func() { + guarded.Accept() + inner.Close() + }() + + // The blocked connection's peer end should observe the connection close rather + // than any protocol banner, since GuardListener closes it before returning it. + buf := make([]byte, 1) + if _, err := blockedPeer.Read(buf); err == nil { + t.Fatal("expected blocked connection to be closed by GuardListener, got readable data instead") + } +} + +type addrOverrideConn struct { + net.Conn + remote net.Addr +} + +func (c *addrOverrideConn) RemoteAddr() net.Addr { return c.remote } + +type hostPortAddr string + +func (hostPortAddr) Network() string { return "tcp" } +func (a hostPortAddr) String() string { return string(a) + ":12345" } diff --git a/internal/config/config.go b/internal/config/config.go index 1ed4a90..10709f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,10 @@ var defaults = []struct { {"server_banner", "", ""}, {"", "", "Time zone for the server"}, {"TIME_ZONE", "Europe/London", ""}, + {"", "", `Comma-separated CIDRs and/or the word "cloudflare" -- only these peers'`}, + {"", "", "X-Forwarded-For/X-Real-IP/CF-Connecting-IP headers are trusted for the real"}, + {"", "", "client IP (audit logs, lockouts). Leave blank if nothing proxies this server."}, + {"trusted_proxies", "", ""}, }}, {"Database", []defaultKV{ {"", "", "Database configuration"}, @@ -87,6 +91,23 @@ var defaults = []struct { {"", "", "Require every mailbox's self-service webmail login to have TOTP/passkey MFA"}, {"", "", "(overridable per-domain or per-mailbox — see the Domains/Mailboxes edit pages)"}, {"enforce_mailbox_mfa", "false", ""}, + {"", "", "Failed login attempts (for one account, from any IP) allowed within the window below"}, + {"", "", "before that account is temporarily locked out, independent of the per-IP rate limit"}, + {"login_attempt_limit", "8", ""}, + {"login_attempt_window_minutes", "15", ""}, + }}, + {"Security", []defaultKV{ + {"", "", "Generated automatically on first run if this file doesn't exist yet — used to sign"}, + {"", "", "CSRF tokens. Back this up like any other secret; don't share it or commit it."}, + {"app_secret_path", "server_data/app_secret.key", ""}, + {"", "", "Automatically block an IP for a while after too many failed SMTP/IMAP login"}, + {"", "", "attempts within the window below (see the Blacklist page in the admin dashboard)"}, + {"abuse_detection_enabled", "true", ""}, + {"abuse_failure_threshold", "8", ""}, + {"abuse_detection_window_minutes", "10", ""}, + {"", "", "First-offense block duration; doubles on each repeat offense up to the cap below"}, + {"abuse_blacklist_base_hours", "12", ""}, + {"abuse_blacklist_max_hours", "168", ""}, }}, {"IMAP", []defaultKV{ {"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"}, diff --git a/internal/db/crud_ip_blacklist.go b/internal/db/crud_ip_blacklist.go new file mode 100644 index 0000000..04e0eae --- /dev/null +++ b/internal/db/crud_ip_blacklist.go @@ -0,0 +1,184 @@ +package db + +import ( + "database/sql" + "errors" + "time" +) + +// IPBlacklistEntry is a temporary (or manual) block on SMTP/IMAP traffic from one IP. +// Deliberately separate from WhitelistedIP (esrv_whitelisted_ips), which authorizes +// unauthenticated relay for a domain — a completely different concern. +type IPBlacklistEntry struct { + ID int64 + IPAddress string + Reason string + OffenseCount int + Manual bool + BlacklistedAt time.Time + ExpiresAt time.Time +} + +// IPAbuseWhitelistEntry exempts one IP from abuse detection entirely. +type IPAbuseWhitelistEntry struct { + ID int64 + IPAddress string + Note string + CreatedAt time.Time +} + +// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse +// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have +// their own lockout in internal/webui/ratelimit.go). +const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login')` + +// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly, +// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but +// scoped by ip_address across all SMTP/IMAP auth types instead of by identifier+one type. +func (d *DB) CountFailedAuthAttemptsByIP(ip string, since time.Time) (int, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs + WHERE ip_address = ? AND success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL, + ip, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n) + return n, err +} + +// IsIPBlacklisted reports whether ip has a currently-active (unexpired) blacklist entry. +func (d *DB) IsIPBlacklisted(ip string) (bool, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE ip_address = ? AND expires_at > ?`, + ip, time.Now().UTC().Format("2006-01-02 15:04:05")).Scan(&n) + return n > 0, err +} + +// IsIPAbuseWhitelisted reports whether ip is exempt from abuse detection. +func (d *DB) IsIPAbuseWhitelisted(ip string) (bool, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_abuse_whitelist WHERE ip_address = ?`, ip).Scan(&n) + return n > 0, err +} + +// BlacklistIP upserts ip's blacklist entry, escalating the block duration on repeat +// offenses: duration = min(baseHours * 2^(offenseCount-1), maxHours), where offenseCount +// is incremented on every call regardless of whether the previous entry had expired. +func (d *DB) BlacklistIP(ip, reason string, baseHours, maxHours int) error { + var existingCount int + err := d.QueryRow(`SELECT offense_count FROM esrv_ip_blacklist WHERE ip_address = ?`, ip).Scan(&existingCount) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + newCount := existingCount + 1 + + hours := baseHours + for i := 1; i < newCount; i++ { + hours *= 2 + if hours >= maxHours { + hours = maxHours + break + } + } + if hours > maxHours { + hours = maxHours + } + expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05") + + _, err = d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at) + VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, ?) + ON CONFLICT(ip_address) DO UPDATE SET + reason = excluded.reason, + offense_count = excluded.offense_count, + manual = 0, + blacklisted_at = CURRENT_TIMESTAMP, + expires_at = excluded.expires_at`, + ip, reason, newCount, expiresAt) + return err +} + +// AddManualBlacklistEntry is an admin-initiated block: fixed duration, no escalation math. +func (d *DB) AddManualBlacklistEntry(ip, reason string, hours int) error { + expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05") + _, err := d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at) + VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, ?) + ON CONFLICT(ip_address) DO UPDATE SET + reason = excluded.reason, + manual = 1, + blacklisted_at = CURRENT_TIMESTAMP, + expires_at = excluded.expires_at`, + ip, reason, expiresAt) + return err +} + +func (d *DB) ListBlacklist() ([]IPBlacklistEntry, error) { + rows, err := d.Query(`SELECT id, ip_address, reason, offense_count, manual, blacklisted_at, expires_at + FROM esrv_ip_blacklist ORDER BY blacklisted_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []IPBlacklistEntry + for rows.Next() { + var e IPBlacklistEntry + var blacklistedAt, expiresAt string + if err := rows.Scan(&e.ID, &e.IPAddress, &e.Reason, &e.OffenseCount, &e.Manual, &blacklistedAt, &expiresAt); err != nil { + return nil, err + } + e.BlacklistedAt, _ = parseTime(blacklistedAt) + e.ExpiresAt, _ = parseTime(expiresAt) + out = append(out, e) + } + return out, rows.Err() +} + +func (d *DB) RemoveBlacklistEntry(id int64) error { + _, err := d.Exec(`DELETE FROM esrv_ip_blacklist WHERE id = ?`, id) + return err +} + +func (d *DB) ListAbuseWhitelist() ([]IPAbuseWhitelistEntry, error) { + rows, err := d.Query(`SELECT id, ip_address, note, created_at FROM esrv_ip_abuse_whitelist ORDER BY ip_address`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []IPAbuseWhitelistEntry + for rows.Next() { + var e IPAbuseWhitelistEntry + var createdAt string + if err := rows.Scan(&e.ID, &e.IPAddress, &e.Note, &createdAt); err != nil { + return nil, err + } + e.CreatedAt, _ = parseTime(createdAt) + out = append(out, e) + } + return out, rows.Err() +} + +func (d *DB) AddAbuseWhitelist(ip, note string) error { + _, err := d.Exec(`INSERT INTO esrv_ip_abuse_whitelist (ip_address, note) VALUES (?, ?) + ON CONFLICT(ip_address) DO UPDATE SET note = excluded.note`, ip, note) + return err +} + +func (d *DB) RemoveAbuseWhitelist(id int64) error { + _, err := d.Exec(`DELETE FROM esrv_ip_abuse_whitelist WHERE id = ?`, id) + return err +} + +// CountBlacklistEventsSince counts blacklist entries (auto or manual) created since the +// given cutoff, for the dashboard's attack-count tiles. +func (d *DB) CountBlacklistEventsSince(since time.Time) (int, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE blacklisted_at >= ?`, + since.UTC().Format("2006-01-02 15:04:05")).Scan(&n) + return n, err +} + +// CountFailedAuthSince counts failed SMTP/IMAP auth attempts (any IP) since the given +// cutoff, for the dashboard's attack-count tiles. +func (d *DB) CountFailedAuthSince(since time.Time) (int, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs + WHERE success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL, + since.UTC().Format("2006-01-02 15:04:05")).Scan(&n) + return n, err +} diff --git a/internal/db/crud_ip_blacklist_test.go b/internal/db/crud_ip_blacklist_test.go new file mode 100644 index 0000000..bd3a179 --- /dev/null +++ b/internal/db/crud_ip_blacklist_test.go @@ -0,0 +1,99 @@ +package db + +import ( + "path/filepath" + "testing" + "time" +) + +func openTestDB(t *testing.T) *DB { + t.Helper() + database, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + return database +} + +// TestBlacklistIPEscalation confirms repeat offenses double the block duration up to the cap. +func TestBlacklistIPEscalation(t *testing.T) { + d := openTestDB(t) + const ip = "203.0.113.7" + + wantHours := []int{12, 24, 48, 96, 168, 168} // caps at 168 (7 days) + for i, want := range wantHours { + if err := d.BlacklistIP(ip, "test", 12, 168); err != nil { + t.Fatalf("offense %d: %v", i+1, err) + } + list, err := d.ListBlacklist() + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("offense %d: expected 1 entry, got %d", i+1, len(list)) + } + e := list[0] + if e.OffenseCount != i+1 { + t.Errorf("offense %d: OffenseCount = %d, want %d", i+1, e.OffenseCount, i+1) + } + gotHours := e.ExpiresAt.Sub(e.BlacklistedAt).Hours() + if diff := gotHours - float64(want); diff < -1 || diff > 1 { + t.Errorf("offense %d: duration = %.1fh, want ~%dh", i+1, gotHours, want) + } + } +} + +// TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp guards against the exact +// SQLite time.Time/CURRENT_TIMESTAMP format mismatch already found once in +// CountRecentFailedAttempts: a row inserted via CURRENT_TIMESTAMP must be found by a +// since-cutoff comparison using a Go-side time.Time a moment earlier. +func TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp(t *testing.T) { + d := openTestDB(t) + const ip = "198.51.100.9" + since := time.Now().Add(-1 * time.Minute) + + if err := d.LogAuthAttempt("sender", "someone@example.com", ip, false, "bad password"); err != nil { + t.Fatal(err) + } + + n, err := d.CountFailedAuthAttemptsByIP(ip, since) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("CountFailedAuthAttemptsByIP = %d, want 1 (CURRENT_TIMESTAMP/time.Time format mismatch?)", n) + } + + blacklisted, err := d.IsIPBlacklisted(ip) + if err != nil { + t.Fatal(err) + } + if blacklisted { + t.Fatal("IP should not be blacklisted yet") + } +} + +func TestIPAbuseWhitelist(t *testing.T) { + d := openTestDB(t) + const ip = "192.0.2.55" + + whitelisted, err := d.IsIPAbuseWhitelisted(ip) + if err != nil { + t.Fatal(err) + } + if whitelisted { + t.Fatal("should not be whitelisted before AddAbuseWhitelist") + } + + if err := d.AddAbuseWhitelist(ip, "trusted scanner"); err != nil { + t.Fatal(err) + } + whitelisted, err = d.IsIPAbuseWhitelisted(ip) + if err != nil { + t.Fatal(err) + } + if !whitelisted { + t.Fatal("should be whitelisted after AddAbuseWhitelist") + } +} diff --git a/internal/db/crud_mailbox_folders.go b/internal/db/crud_mailbox_folders.go new file mode 100644 index 0000000..0fff404 --- /dev/null +++ b/internal/db/crud_mailbox_folders.go @@ -0,0 +1,45 @@ +package db + +// CreateMailboxFolder records a custom folder's existence even before it holds any +// messages — idempotent (a folder a filter rule already delivered into can be +// explicitly created too, without erroring on the duplicate). +func (d *DB) CreateMailboxFolder(mailboxID int64, name string) error { + _, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name) VALUES (?, ?)`, mailboxID, name) + return err +} + +// DeleteMailboxFolder removes a custom folder's record. Callers are responsible for +// relocating any messages still in it first (see MoveAllMessagesInFolder) — this +// alone doesn't touch esrv_mailbox_messages. +func (d *DB) DeleteMailboxFolder(mailboxID int64, name string) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name) + return err +} + +// ListMailboxFolders returns a mailbox's explicitly-created custom folders — combine +// with DistinctFoldersForMailbox (message-derived) for the full folder list, since a +// folder can exist via either path (or both). +func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) { + rows, err := d.Query(`SELECT name FROM esrv_mailbox_folders WHERE mailbox_id = ? ORDER BY name`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +// MoveAllMessagesInFolder reassigns every message in one folder to another — used +// when deleting a custom folder, so its messages land in INBOX instead of becoming +// orphaned in a folder nothing lists anymore. +func (d *DB) MoveAllMessagesInFolder(mailboxID int64, from, to string) error { + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, to, mailboxID, from) + return err +} diff --git a/internal/db/crud_mailbox_messages.go b/internal/db/crud_mailbox_messages.go index ea766f0..a0ae151 100644 --- a/internal/db/crud_mailbox_messages.go +++ b/internal/db/crud_mailbox_messages.go @@ -3,36 +3,47 @@ package db import ( "database/sql" "errors" + "strings" "time" ) // InsertMessage records a stored message's index row (the ciphertext itself already // lives at storagePath — see internal/mailstore). Returns the new row's id, which // doubles as the IMAP UID in later milestones. -func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedSubject string) (int64, error) { +func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject string) (int64, error) { res, err := d.Exec(`INSERT INTO esrv_mailbox_messages - (mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_subject) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedSubject) + (mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject) if err != nil { return 0, err } return res.LastInsertId() } -func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) { - row := d.QueryRow(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at - FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID) +const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, storage_path, nonce, created_at` + +func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) { var m MailboxMessage var internalDate, createdAt string - if err := row.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil { + err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt) + if err != nil { + return m, err + } + m.InternalDate, _ = parseTime(internalDate) + m.CreatedAt, _ = parseTime(createdAt) + return m, nil +} + +func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) { + row := d.QueryRow(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID) + m, err := scanMailboxMessage(row.Scan) + if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } - m.InternalDate, _ = parseTime(internalDate) - m.CreatedAt, _ = parseTime(createdAt) return &m, nil } @@ -41,6 +52,13 @@ func (d *DB) DeleteMessage(mailboxID, uid int64) error { return err } +// MoveMessage reassigns a message to a different folder — pure metadata change, the +// on-disk ciphertext at storage_path never moves. +func (d *DB) MoveMessage(mailboxID, uid int64, newFolder string) error { + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE id = ? AND mailbox_id = ?`, newFolder, uid, mailboxID) + return err +} + // ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by // mailbox removal to delete each one's on-disk ciphertext via mailstore before the // mailbox row itself is removed. @@ -61,28 +79,28 @@ func (d *DB) ListMessageUIDsForMailbox(mailboxID int64) ([]int64, error) { return out, rows.Err() } +func scanMailboxMessages(rows *sql.Rows) ([]MailboxMessage, error) { + defer rows.Close() + var out []MailboxMessage + for rows.Next() { + m, err := scanMailboxMessage(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + // ListMessagesForMailbox returns every stored message's full row for mailboxID, // ordered ascending by UID (id) — this ordering IS the IMAP sequence-number mapping // (index+1 == seqNum) that internal/imapserver relies on. func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) { - rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at - FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID) + rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID) if err != nil { return nil, err } - defer rows.Close() - var out []MailboxMessage - for rows.Next() { - var m MailboxMessage - var internalDate, createdAt string - if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil { - return nil, err - } - m.InternalDate, _ = parseTime(internalDate) - m.CreatedAt, _ = parseTime(createdAt) - out = append(out, m) - } - return out, rows.Err() + return scanMailboxMessages(rows) } // SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped @@ -96,22 +114,129 @@ func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error { // uses this (not the unscoped version) so a filter rule's move_to_folder action produces // mail that's actually browsable in its own folder, not mixed into every SELECT. func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMessage, error) { - rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at - FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder) + rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder) + if err != nil { + return nil, err + } + return scanMailboxMessages(rows) +} + +// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for +// the webmail client's folder view — a mailbox can accumulate far more mail than is +// reasonable to render in one page. +func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) { + rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages + WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset) + if err != nil { + return nil, err + } + return scanMailboxMessages(rows) +} + +// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls. +func (d *DB) CountMessagesInFolder(mailboxID int64, folder string) (int, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`, mailboxID, folder).Scan(&n) + return n, err +} + +// escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters +// (%, _, and the escape character itself) so a search for e.g. "50% off" or a +// filename with an underscore doesn't get interpreted as a wildcard. +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +// SearchMessagesInFolder finds messages whose cached subject/from/to contain query +// (case-insensitive substring, not a full-text index — see the webmail search +// handler's doc comment for why that's the deliberate scope here), newest first. +// folder == "" searches every folder in the mailbox. +func (d *DB) SearchMessagesInFolder(mailboxID int64, folder, query string, offset, limit int) ([]MailboxMessage, error) { + like := "%" + escapeLike(query) + "%" + args := []any{mailboxID} + folderClause := "" + if folder != "" { + folderClause = "AND folder = ? " + args = append(args, folder) + } + args = append(args, like, like, like, limit, offset) + rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages + WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\') + ORDER BY id DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, err + } + return scanMailboxMessages(rows) +} + +// CountSearchMessagesInFolder backs SearchMessagesInFolder's pagination controls. +func (d *DB) CountSearchMessagesInFolder(mailboxID int64, folder, query string) (int, error) { + like := "%" + escapeLike(query) + "%" + args := []any{mailboxID} + folderClause := "" + if folder != "" { + folderClause = "AND folder = ? " + args = append(args, folder) + } + args = append(args, like, like, like) + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages + WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')`, args...).Scan(&n) + return n, err +} + +// CountUnreadByFolder returns every folder's unread count in one query (GROUP BY, +// not one query per folder) — mirrors isUnread's own check +// (internal/webui/webmail_mail.go) but done in SQL so the sidebar's badge counts are +// cheap to compute on every folder-view render without a full row fetch. A folder +// with zero unread messages simply has no entry in the returned map. +func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) { + rows, err := d.Query(`SELECT folder, COUNT(*) FROM esrv_mailbox_messages + WHERE mailbox_id = ? AND flags NOT LIKE '%\Seen%' GROUP BY folder`, mailboxID) if err != nil { return nil, err } defer rows.Close() - var out []MailboxMessage + out := map[string]int{} for rows.Next() { - var m MailboxMessage - var internalDate, createdAt string - if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil { + var folder string + var n int + if err := rows.Scan(&folder, &n); err != nil { return nil, err } - m.InternalDate, _ = parseTime(internalDate) - m.CreatedAt, _ = parseTime(createdAt) - out = append(out, m) + out[folder] = n + } + return out, rows.Err() +} + +// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a +// display name like "Name " is kept as-is, not parsed apart, since +// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously +// exchanged mail with — its own Sent "To" list plus INBOX "From" senders — whose +// value contains prefix. Backs the compose recipient autocomplete; deliberately +// reuses message history already stored rather than a dedicated contacts table. +func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error) { + like := "%" + escapeLike(prefix) + "%" + rows, err := d.Query(` + SELECT addr FROM ( + SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != '' + UNION + SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != '' + ) + WHERE addr LIKE ? ESCAPE '\' + ORDER BY addr LIMIT 10`, mailboxID, mailboxID, like) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var addr string + if err := rows.Scan(&addr); err != nil { + return nil, err + } + out = append(out, addr) } return out, rows.Err() } diff --git a/internal/db/crud_mailbox_pgp.go b/internal/db/crud_mailbox_pgp.go new file mode 100644 index 0000000..63217e2 --- /dev/null +++ b/internal/db/crud_mailbox_pgp.go @@ -0,0 +1,125 @@ +package db + +import ( + "database/sql" + "errors" +) + +// CreatePGPIdentity adds a new PGP identity for a mailbox — a mailbox may hold +// several at once (see esrv_mailbox_pgp_identities in schema.go). +func (d *DB) CreatePGPIdentity(mailboxID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) { + res, err := d.Exec(` + INSERT INTO esrv_mailbox_pgp_identities (mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor) + VALUES (?, ?, ?, ?, ?, ?) + `, mailboxID, label, email, fingerprint, publicKeyArmor, privateKeyArmor) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// ListPGPIdentities returns a mailbox's PGP identities, most recent first. +func (d *DB) ListPGPIdentities(mailboxID int64) ([]MailboxPGPIdentity, error) { + rows, err := d.Query(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at + FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailboxPGPIdentity + for rows.Next() { + var id MailboxPGPIdentity + if err := rows.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// GetPGPIdentity returns nil, nil if no such identity exists for this mailbox — +// scoped to mailboxID so one mailbox owner can't reach another's identity by +// guessing its ID. +func (d *DB) GetPGPIdentity(mailboxID, identityID int64) (*MailboxPGPIdentity, error) { + row := d.QueryRow(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at + FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID) + var id MailboxPGPIdentity + if err := row.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &id, nil +} + +// DeletePGPIdentity removes one identity, scoped to mailboxID. +func (d *DB) DeletePGPIdentity(mailboxID, identityID int64) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID) + return err +} + +// UpsertPGPContact adds a contact's PGP public key, replacing any existing key +// already on file for that email (e.g. after the contact rotates their key). +func (d *DB) UpsertPGPContact(mailboxID int64, email, label, fingerprint, publicKeyArmor string) error { + _, err := d.Exec(` + INSERT INTO esrv_mailbox_pgp_contacts (mailbox_id, email, label, fingerprint, public_key_armor) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(mailbox_id, email) DO UPDATE SET label = excluded.label, fingerprint = excluded.fingerprint, public_key_armor = excluded.public_key_armor + `, mailboxID, email, label, fingerprint, publicKeyArmor) + return err +} + +// GetPGPContact returns nil, nil if no key is on file for that email. +func (d *DB) GetPGPContact(mailboxID int64, email string) (*MailboxPGPContact, error) { + row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email) + var c MailboxPGPContact + if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &c, nil +} + +// GetPGPContactByID returns nil, nil if no such contact exists for this mailbox — +// scoped to mailboxID so one mailbox owner can't reach another's contact by guessing +// its ID. Used by compose's recipient-key picker, which selects contacts by ID +// rather than matching a To/Cc/Bcc address against GetPGPContact's stored email. +func (d *DB) GetPGPContactByID(mailboxID, contactID int64) (*MailboxPGPContact, error) { + row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID) + var c MailboxPGPContact + if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &c, nil +} + +// ListPGPContacts returns a mailbox's collected contact keys, alphabetical by email. +func (d *DB) ListPGPContacts(mailboxID int64) ([]MailboxPGPContact, error) { + rows, err := d.Query(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailboxPGPContact + for rows.Next() { + var c MailboxPGPContact + if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// DeletePGPContact removes one contact key, scoped to mailboxID so one mailbox +// owner can't delete another's contact by guessing its ID. +func (d *DB) DeletePGPContact(mailboxID, contactID int64) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID) + return err +} diff --git a/internal/db/crud_mailbox_rules.go b/internal/db/crud_mailbox_rules.go index e925688..37dbab4 100644 --- a/internal/db/crud_mailbox_rules.go +++ b/internal/db/crud_mailbox_rules.go @@ -1,7 +1,9 @@ package db +import "encoding/json" + func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) { - rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, created_at + rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID) if err != nil { return nil, err @@ -11,7 +13,7 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) { for rows.Next() { var r MailboxFilterRule var createdAt string - if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &createdAt); err != nil { + if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &r.ConditionsJSON, &r.MatchType, &createdAt); err != nil { return nil, err } r.CreatedAt, _ = parseTime(createdAt) @@ -20,9 +22,28 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) { return out, rows.Err() } +// CreateRule creates a single-condition rule — a thin wrapper over CreateRuleMulti +// for the common one-condition case (and for existing callers/tests written before +// multi-condition rules existed). func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) { - res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value) - VALUES (?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, field, op, value, action, actionValue) + return d.CreateRuleMulti(mailboxID, priority, []RuleCondition{{Field: field, Op: op, Value: value}}, "all", action, actionValue) +} + +// CreateRuleMulti creates a rule with one or more conditions combined per matchType +// ("all"=AND, "any"=OR, defaulting to "all" for anything else). The first condition +// also mirrors into the legacy condition_field/op/value columns so old code paths +// reading them directly still see something sane. +func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCondition, matchType, action, actionValue string) (int64, error) { + if matchType != "any" { + matchType = "all" + } + conditionsJSON, err := json.Marshal(conditions) + if err != nil { + return 0, err + } + first := conditions[0] + res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, conditions_json, match_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, first.Field, first.Op, first.Value, action, actionValue, string(conditionsJSON), matchType) if err != nil { return 0, err } diff --git a/internal/db/crud_mailbox_smime.go b/internal/db/crud_mailbox_smime.go new file mode 100644 index 0000000..709fd7b --- /dev/null +++ b/internal/db/crud_mailbox_smime.go @@ -0,0 +1,111 @@ +package db + +import ( + "database/sql" + "errors" + "time" +) + +// CreateSMIMEIdentity adds a new S/MIME identity for a mailbox — a mailbox may hold +// several at once (see esrv_mailbox_smime_identities in schema.go). +func (d *DB) CreateSMIMEIdentity(mailboxID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) { + res, err := d.Exec(` + INSERT INTO esrv_mailbox_smime_identities (mailbox_id, cert_pem, key_pem, not_after) + VALUES (?, ?, ?, ?) + `, mailboxID, certPEM, keyPEM, notAfter) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// ListSMIMEIdentities returns a mailbox's S/MIME identities, most recent first. +func (d *DB) ListSMIMEIdentities(mailboxID int64) ([]MailboxSMIMEIdentity, error) { + rows, err := d.Query(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at + FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailboxSMIMEIdentity + for rows.Next() { + var id MailboxSMIMEIdentity + if err := rows.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// GetSMIMEIdentity returns nil, nil if no such identity exists for this mailbox — +// scoped to mailboxID so one mailbox owner can't reach another's identity by +// guessing its ID. +func (d *DB) GetSMIMEIdentity(mailboxID, identityID int64) (*MailboxSMIMEIdentity, error) { + row := d.QueryRow(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at + FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID) + var id MailboxSMIMEIdentity + if err := row.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &id, nil +} + +// DeleteSMIMEIdentity removes one identity, scoped to mailboxID. +func (d *DB) DeleteSMIMEIdentity(mailboxID, identityID int64) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID) + return err +} + +// UpsertSMIMEContact adds a contact certificate, replacing any existing certificate +// already on file for that email (e.g. after the contact renews their cert). +func (d *DB) UpsertSMIMEContact(mailboxID int64, email, certPEM string) error { + _, err := d.Exec(` + INSERT INTO esrv_mailbox_smime_contacts (mailbox_id, email, cert_pem) + VALUES (?, ?, ?) + ON CONFLICT(mailbox_id, email) DO UPDATE SET cert_pem = excluded.cert_pem + `, mailboxID, email, certPEM) + return err +} + +// GetSMIMEContact returns nil, nil if no certificate is on file for that email. +func (d *DB) GetSMIMEContact(mailboxID int64, email string) (*MailboxSMIMEContact, error) { + row := d.QueryRow(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email) + var c MailboxSMIMEContact + if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &c, nil +} + +// ListSMIMEContacts returns a mailbox's collected contact certificates, alphabetical +// by email. +func (d *DB) ListSMIMEContacts(mailboxID int64) ([]MailboxSMIMEContact, error) { + rows, err := d.Query(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailboxSMIMEContact + for rows.Next() { + var c MailboxSMIMEContact + if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// DeleteSMIMEContact removes one contact certificate, scoped to mailboxID so one +// mailbox owner can't delete another's contact by guessing its ID. +func (d *DB) DeleteSMIMEContact(mailboxID, contactID int64) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID) + return err +} diff --git a/internal/db/crud_mailboxes.go b/internal/db/crud_mailboxes.go index d120e8c..530470f 100644 --- a/internal/db/crud_mailboxes.go +++ b/internal/db/crud_mailboxes.go @@ -195,6 +195,11 @@ func (d *DB) RemoveMailboxCascade(id int64) error { `DELETE FROM esrv_mailbox_filter_rules WHERE mailbox_id = ?`, `DELETE FROM esrv_mailbox_sessions WHERE mailbox_id = ?`, `DELETE FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`, + `DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ?`, + `DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ?`, + `DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ?`, + `DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ?`, + `DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ?`, `DELETE FROM esrv_mailbox_messages WHERE mailbox_id = ?`, `DELETE FROM esrv_mailboxes WHERE id = ?`, } { diff --git a/internal/db/mailbox_models.go b/internal/db/mailbox_models.go index 3d3305a..1dd6e68 100644 --- a/internal/db/mailbox_models.go +++ b/internal/db/mailbox_models.go @@ -1,6 +1,9 @@ package db -import "time" +import ( + "encoding/json" + "time" +) // Mailbox is a real, IMAP-retrievable local mailbox — distinct from Sender (which is // relay/auth-only). PasswordHash authenticates the self-service web portal only; @@ -68,6 +71,8 @@ type MailboxAllowBlockEntry struct { } // MailboxFilterRule is one priority-ordered, first-match-wins delivery rule. +// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON +// (when non-empty) is the current multi-condition representation — see Conditions(). type MailboxFilterRule struct { ID int64 MailboxID int64 @@ -75,12 +80,40 @@ type MailboxFilterRule struct { ConditionField string // "from" | "to" | "subject" ConditionOp string // "contains" | "equals" | "starts_with" ConditionValue string - Action string // "move_to_folder" | "delete" | "mark_read" + Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam" ActionValue string IsActive bool + ConditionsJSON string + MatchType string // "all" (AND, default) | "any" (OR) CreatedAt time.Time } +// RuleCondition is one condition within a filter rule's "if" clause. +type RuleCondition struct { + Field string `json:"field"` + Op string `json:"op"` + Value string `json:"value"` +} + +// Conditions returns this rule's conditions and how they combine ("all"=AND, +// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy +// condition_field/op/value columns for rules created before multi-condition support +// existed. Shared by mailstore.ApplyRules (evaluation) and the webui (display), so +// both stay in sync with the same fallback rule. +func (r MailboxFilterRule) Conditions() ([]RuleCondition, string) { + if r.ConditionsJSON != "" { + var parsed []RuleCondition + if err := json.Unmarshal([]byte(r.ConditionsJSON), &parsed); err == nil && len(parsed) > 0 { + matchType := r.MatchType + if matchType != "any" { + matchType = "all" + } + return parsed, matchType + } + } + return []RuleCondition{{Field: r.ConditionField, Op: r.ConditionOp, Value: r.ConditionValue}}, "all" +} + // MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext // is shown once at creation and never stored. ExpiresAt is nil for a password that // never expires (the default). @@ -95,8 +128,10 @@ type MailboxAppPassword struct { ExpiresAt *time.Time } -// MailboxMessage is one stored message. CachedFrom/CachedSubject are plaintext by -// design (see schema.go); the rest of the message lives encrypted at StoragePath. +// MailboxMessage is one stored message. CachedFrom/CachedTo/CachedSubject are +// plaintext by design (see schema.go); the rest of the message lives encrypted at +// StoragePath. CachedTo exists purely so folder listings (e.g. Sent) can show the +// recipient without decrypting every message just to render a list. type MailboxMessage struct { ID int64 MailboxID int64 @@ -106,8 +141,60 @@ type MailboxMessage struct { InternalDate time.Time SizeBytes int64 CachedFrom string + CachedTo string CachedSubject string StoragePath string Nonce []byte CreatedAt time.Time } + +// MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key +// pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is +// sign-only in this codebase, so the key never protects anything beyond what the +// server already has access to. +type MailboxSMIMEIdentity struct { + ID int64 + MailboxID int64 + CertPEM string + KeyPEM string + NotAfter time.Time + CreatedAt time.Time +} + +// MailboxSMIMEContact is another address's public certificate a mailbox owner has +// collected, either added by hand or auto-captured off a verified signature. +type MailboxSMIMEContact struct { + ID int64 + MailboxID int64 + Email string + CertPEM string + CreatedAt time.Time +} + +// MailboxPGPIdentity is one of a mailbox's own PGP keypairs — a mailbox may hold +// several. PrivateKeyArmor is stored exactly as the pgp package serializes it, +// already passphrase-protected via OpenPGP's own native key-encryption format (no +// separate ciphertext/nonce/salt columns needed, unlike MailboxSMIMEIdentity). +// Label is a free-text user note distinguishing keys (PGP keys have no expiry). +type MailboxPGPIdentity struct { + ID int64 + MailboxID int64 + Label string + Email string + Fingerprint string + PublicKeyArmor string + PrivateKeyArmor string + CreatedAt time.Time +} + +// MailboxPGPContact is another address's PGP public key a mailbox owner has +// collected — mirrors MailboxSMIMEContact. +type MailboxPGPContact struct { + ID int64 + MailboxID int64 + Email string + Label string + PublicKeyArmor string + Fingerprint string + CreatedAt time.Time +} diff --git a/internal/db/queries.go b/internal/db/queries.go index c338bbd..d1e8d62 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -132,6 +132,33 @@ func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool return err } +// CountRecentFailedAttempts counts failed esrv_auth_logs rows for one identifier +// (independent of which IP each attempt came from — a distributed credential- +// stuffing attempt against a single account should still trip this) within +// authType and since the given cutoff, powering the per-account lockout in +// internal/webui/login.go and webmail_login.go. +func (d *DB) CountRecentFailedAttempts(authType, identifier string, since time.Time) (int, error) { + var n int + // created_at is populated by SQLite's own CURRENT_TIMESTAMP: a plain + // "YYYY-MM-DD HH:MM:SS" UTC string, space-separated, no fractional seconds, no + // offset. modernc.org/sqlite instead binds a Go time.Time query parameter as + // RFC3339Nano with a zone offset (e.g. "2026-08-14T06:57:50.497566315+01:00") — + // a live check confirmed this by inserting a time.Time into a real column and + // reading the stored text back. That format is structurally different from + // CURRENT_TIMESTAMP's own (different separator, precision, and offset), so a + // plain text >= comparison between the two doesn't reflect chronological order at + // all (confirmed: it silently matched zero rows). Two Go-bound time.Time values + // compared against each other DO work correctly, since the driver formats both + // identically — this only breaks when one side is a raw SQL CURRENT_TIMESTAMP + // default and the other is a Go-bound parameter, which happens on THIS column but + // nowhere else in this codebase (checked every other DATETIME comparison). + // Formatting since into CURRENT_TIMESTAMP's exact layout makes both sides match. + err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs + WHERE auth_type = ? AND identifier = ? AND success = 0 AND created_at >= ?`, + authType, identifier, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n) + return n, err +} + func parseTime(s string) (time.Time, error) { for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} { if t, err := time.Parse(layout, s); err == nil { diff --git a/internal/db/schema.go b/internal/db/schema.go index e625b44..5f922d6 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -85,6 +85,40 @@ CREATE TABLE IF NOT EXISTS esrv_auth_logs ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- Matches CountRecentFailedAttempts' lockout-check query. +CREATE INDEX IF NOT EXISTS idx_auth_logs_lockout ON esrv_auth_logs(identifier, auth_type, created_at); +-- Matches CountFailedAuthAttemptsByIP's abuse-detection query (internal/abuseguard) — +-- a different access pattern than the lockout index above (by IP, not identifier). +CREATE INDEX IF NOT EXISTS idx_auth_logs_by_ip ON esrv_auth_logs(ip_address, created_at); + +-- Temporary IP blocks, auto-created by internal/abuseguard when one IP racks up too +-- many failed SMTP/IMAP auth attempts within a short window (see +-- CountFailedAuthAttemptsByIP), or manually by an admin from the Blacklist page. +-- offense_count drives escalating block duration on repeat offenders — see +-- BlacklistIP's doc comment for the exact formula. Deliberately separate from +-- esrv_whitelisted_ips (which authorizes unauthenticated relay for a domain, a +-- completely different concern) and from the web login lockout in +-- internal/webui/ratelimit.go (which never touches this table). +CREATE TABLE IF NOT EXISTS esrv_ip_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL UNIQUE, + reason TEXT NOT NULL DEFAULT '', + offense_count INTEGER NOT NULL DEFAULT 1, + manual INTEGER NOT NULL DEFAULT 0, + blacklisted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expiry ON esrv_ip_blacklist(ip_address, expires_at); + +-- IPs exempt from abuse detection (internal/abuseguard never blacklists or blocks +-- these) — again deliberately separate from esrv_whitelisted_ips. +CREATE TABLE IF NOT EXISTS esrv_ip_abuse_whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL UNIQUE, + note TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS esrv_dkim_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), @@ -239,6 +273,12 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock ( -- Simple first-match-wins filter rules, evaluated in priority order (lower first) at -- delivery time, before a message is encrypted and stored — so from/to/subject -- matching works against the real message, not just the plaintext cache columns below. +-- condition_field/op/value are the legacy single-condition columns, kept for rows +-- created before multi-condition support existed. Every rule created since then +-- stores its full condition list in conditions_json (a JSON array of +-- {field,op,value}) instead, combined per match_type ("all"=AND, "any"=OR); a rule +-- with an empty conditions_json falls back to the legacy columns as a single +-- condition — see MailboxFilterRule.Conditions() in mailbox_models.go. CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), @@ -246,9 +286,11 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules ( condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')), condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')), condition_value TEXT NOT NULL, - action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')), + action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam')), action_value TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, + conditions_json TEXT NOT NULL DEFAULT '', + match_type TEXT NOT NULL DEFAULT 'all', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -265,11 +307,96 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages ( internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, size_bytes INTEGER NOT NULL, cached_from TEXT NOT NULL DEFAULT '', + cached_to TEXT NOT NULL DEFAULT '', cached_subject TEXT NOT NULL DEFAULT '', storage_path TEXT NOT NULL, nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + +-- Matches the folder view's exact WHERE mailbox_id = ? AND folder = ? ORDER BY +-- internal_date pattern — the single hottest query in the whole webmail client, and +-- previously unindexed (this schema had no indexes at all before this one). +CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages(mailbox_id, folder, internal_date); + +-- Explicit record of a mailbox's custom folders, so a freshly created (still empty) +-- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove +-- a folder exists once it holds at least one message. Standard folders (INBOX, Spam, +-- Sent, Drafts, Trash) are never stored here; they're always shown by the webui +-- regardless of this table. +CREATE TABLE IF NOT EXISTS esrv_mailbox_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(mailbox_id, name) +); + +-- A mailbox's own S/MIME identities — a mailbox may hold several at once (e.g. one +-- per external party it corresponds with, or after rotating an expiring one while +-- keeping the old one around to read old mail). S/MIME is sign-only in this +-- codebase (PGP handles encryption — see esrv_mailbox_pgp_identities below), so the +-- private key is stored plain, same trust model as the PGP private key column: the +-- server already holds everything needed to use it, with no separate +-- passphrase-derived wrapper (that was tried and removed — see git history — it was +-- pure friction for an asset that was never actually protecting anything a server +-- compromise wouldn't already expose). +-- Superseded esrv_mailbox_smime_identity (singular, one auto-unwrapped identity per +-- mailbox) is left in place unused rather than migrated. +CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + cert_pem TEXT NOT NULL, + key_pem TEXT NOT NULL, + not_after DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Other people's public certificates a mailbox owner has collected — added by hand +-- or auto-captured off a verified incoming signature. Used to offer "Encrypt" for a +-- recipient in compose and to flag a known signer on read; never chain-validated +-- against a CA (see internal/smime package doc). +CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + email TEXT NOT NULL, + cert_pem TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(mailbox_id, email) +); + +-- A mailbox's own PGP keys — used only for encryption in this codebase (S/MIME, +-- above, handles signing). A mailbox may hold several. OpenPGP's own private key +-- packet format carries its own passphrase protection natively (see +-- pgp.GenerateKeyPair's doc comment) — private_key_armor is stored exactly as the +-- library serializes it, already passphrase-protected (unlike S/MIME's key_pem, +-- which is stored plain). +-- label is a free-text user note (PGP keys have no expiry to distinguish them by the +-- way generated S/MIME certs do). +CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + label TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL, + fingerprint TEXT NOT NULL, + public_key_armor TEXT NOT NULL, + private_key_armor TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Other people's PGP public keys a mailbox owner has collected, added by hand — +-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a +-- recipient in compose. +CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + email TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + public_key_armor TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(mailbox_id, email) +); ` // migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains @@ -292,6 +419,19 @@ func migrateAddedColumns(db *sql.DB) { `ALTER TABLE esrv_admin_users ADD COLUMN must_change_username INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`, + // conditions_json/match_type are retrofittable via ALTER TABLE, but the action + // CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support + // altering a CHECK on an existing table. A dev DB created before this change + // would need recreating to accept a mark_as_spam rule; a fresh install gets it + // for free from the CREATE TABLE above. + `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`, + // key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt + // columns — a dev DB with pre-existing identities just loses their (now + // unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated" + // treatment as the singular-table identities before them. + `ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`, } for _, stmt := range stmts { db.Exec(stmt) diff --git a/internal/imapserver/backend.go b/internal/imapserver/backend.go index f5d9e17..bdd05f9 100644 --- a/internal/imapserver/backend.go +++ b/internal/imapserver/backend.go @@ -10,6 +10,8 @@ import ( "mailgoserver/internal/db" "mailgoserver/internal/mailstore" "mailgoserver/internal/toolbox" + + "gopkg.in/ini.v1" ) // Backend holds the shared dependencies every connection's Session uses, mirroring @@ -18,8 +20,9 @@ type Backend struct { DB *db.DB Mailstore *mailstore.Store Logger *toolbox.Logger + Cfg *ini.File } -func (b *Backend) NewSession() *Session { - return &Session{backend: b} +func (b *Backend) NewSession(peerIP string) *Session { + return &Session{backend: b, peerIP: peerIP} } diff --git a/internal/imapserver/server.go b/internal/imapserver/server.go index 66ba527..88cb669 100644 --- a/internal/imapserver/server.go +++ b/internal/imapserver/server.go @@ -2,13 +2,18 @@ package imapserver import ( "crypto/tls" + "net" goimapserver "github.com/emersion/go-imap/v2/imapserver" ) func newSessionFunc(backend *Backend) func(*goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) { return func(c *goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) { - return backend.NewSession(), nil, nil + host, _, err := net.SplitHostPort(c.NetConn().RemoteAddr().String()) + if err != nil { + host = c.NetConn().RemoteAddr().String() + } + return backend.NewSession(host), nil, nil } } diff --git a/internal/imapserver/session.go b/internal/imapserver/session.go index 1ad24e0..823bd75 100644 --- a/internal/imapserver/session.go +++ b/internal/imapserver/session.go @@ -9,6 +9,7 @@ import ( "github.com/emersion/go-imap/v2" goimapserver "github.com/emersion/go-imap/v2/imapserver" + "mailgoserver/internal/abuseguard" "mailgoserver/internal/db" ) @@ -24,6 +25,7 @@ var _ goimapserver.Session = (*Session)(nil) // just not an instant push. Add a tracker if that matters. type Session struct { backend *Backend + peerIP string mailbox *db.Mailbox // set once Login succeeds selectedFolder string // set by Select; defaults to INBOX if empty } @@ -35,11 +37,16 @@ func (s *Session) Close() error { return nil } func (s *Session) Login(username, password string) error { mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password) if err != nil { + _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Authentication error: "+err.Error()) + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return err } if mbox == nil { + _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Invalid credentials") + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return goimapserver.ErrAuthFailed } + _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, true, "Successful IMAP app-password authentication") s.mailbox = mbox return nil } diff --git a/internal/mailstore/rules.go b/internal/mailstore/rules.go index f08522d..927f6ef 100644 --- a/internal/mailstore/rules.go +++ b/internal/mailstore/rules.go @@ -1,6 +1,10 @@ package mailstore -import "strings" +import ( + "strings" + + "mailgoserver/internal/db" +) // FilterAction is the outcome of evaluating a mailbox's filter rules against one // incoming message. @@ -24,12 +28,17 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc if !r.IsActive { continue } - if !matchCondition(r.ConditionOp, headers[r.ConditionField], r.ConditionValue) { + if !ruleMatches(r, headers) { continue } switch r.Action { case "move_to_folder": return FilterAction{Folder: r.ActionValue}, nil + case "mark_as_spam": + // Reuses the same Spam folder score-based quarantine already delivers + // into (see smtpserver/session.go) — from the mailbox owner's + // perspective it's the same "goes to Spam" outcome either way. + return FilterAction{Folder: "Spam"}, nil case "delete": return FilterAction{Drop: true}, nil case "mark_read": @@ -39,6 +48,26 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc return FilterAction{}, nil } +// ruleMatches combines a rule's conditions per its match type: "all" requires every +// condition to match (AND), "any" requires at least one (OR). +func ruleMatches(r db.MailboxFilterRule, headers map[string]string) bool { + conditions, matchType := r.Conditions() + if matchType == "any" { + for _, c := range conditions { + if matchCondition(c.Op, headers[c.Field], c.Value) { + return true + } + } + return false + } + for _, c := range conditions { + if !matchCondition(c.Op, headers[c.Field], c.Value) { + return false + } + } + return true +} + func matchCondition(op, value, target string) bool { value = strings.ToLower(value) target = strings.ToLower(target) diff --git a/internal/mailstore/rules_test.go b/internal/mailstore/rules_test.go new file mode 100644 index 0000000..8d47d42 --- /dev/null +++ b/internal/mailstore/rules_test.go @@ -0,0 +1,107 @@ +package mailstore + +import ( + "testing" + + "mailgoserver/internal/db" +) + +// TestApplyRulesMultiConditionAnd confirms an "all" (AND) rule only matches when +// every condition matches. +func TestApplyRulesMultiConditionAnd(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + conditions := []db.RuleCondition{ + {Field: "to", Op: "contains", Value: "sales"}, + {Field: "subject", Op: "contains", Value: "invoice"}, + } + if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "move_to_folder", "Invoices"); err != nil { + t.Fatal(err) + } + + // Matches "to" only — AND rule should not fire. + action, err := s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "hello"}) + if err != nil { + t.Fatal(err) + } + if action.Folder != "" { + t.Fatalf("expected no match with only one AND condition satisfied, got folder=%q", action.Folder) + } + + // Matches both — AND rule should fire. + action, err = s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "your invoice"}) + if err != nil { + t.Fatal(err) + } + if action.Folder != "Invoices" { + t.Fatalf("expected move to Invoices when both AND conditions match, got %+v", action) + } +} + +// TestApplyRulesMultiConditionOr confirms an "any" (OR) rule matches when at least +// one condition matches. +func TestApplyRulesMultiConditionOr(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + conditions := []db.RuleCondition{ + {Field: "from", Op: "contains", Value: "boss@work.example"}, + {Field: "subject", Op: "contains", Value: "urgent"}, + } + if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "any", "mark_read", ""); err != nil { + t.Fatal(err) + } + + action, err := s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "urgent: read me"}) + if err != nil { + t.Fatal(err) + } + if !action.MarkRead { + t.Fatalf("expected OR rule to fire on subject match alone, got %+v", action) + } + + action, err = s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "hello"}) + if err != nil { + t.Fatal(err) + } + if action.MarkRead { + t.Fatalf("expected OR rule not to fire when neither condition matches, got %+v", action) + } +} + +// TestApplyRulesMarkAsSpam confirms the mark_as_spam action routes into the Spam +// folder, same as score-based quarantine. +func TestApplyRulesMarkAsSpam(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "viagra"}} + if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "mark_as_spam", ""); err != nil { + t.Fatal(err) + } + + action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "cheap viagra now"}) + if err != nil { + t.Fatal(err) + } + if action.Folder != "Spam" { + t.Fatalf("expected mark_as_spam to route into the Spam folder, got %+v", action) + } +} + +// TestApplyRulesLegacySingleConditionFallback confirms a rule row with an empty +// ConditionsJSON (as any rule created before multi-condition support existed would +// have) still evaluates correctly via the legacy condition_field/op/value columns. +func TestApplyRulesLegacySingleConditionFallback(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + // CreateRule (not CreateRuleMulti) still writes conditions_json today, so to + // simulate genuinely old pre-migration data we insert directly with an empty + // conditions_json, exactly as an old row would look on disk. + if _, err := s.DB.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value) + VALUES (?, 0, 'subject', 'contains', 'newsletter', 'delete', '')`, mailboxID); err != nil { + t.Fatal(err) + } + + action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "weekly newsletter"}) + if err != nil { + t.Fatal(err) + } + if !action.Drop { + t.Fatalf("expected the legacy single-condition rule to still match, got %+v", action) + } +} diff --git a/internal/mailstore/store.go b/internal/mailstore/store.go index 3627e2a..0e30cdd 100644 --- a/internal/mailstore/store.go +++ b/internal/mailstore/store.go @@ -1,15 +1,30 @@ package mailstore import ( + "bytes" "crypto/rand" "encoding/hex" "errors" "fmt" + "net/mail" "os" "path/filepath" "time" ) +// extractHeaderValue reads a single header out of raw without parsing the body — used +// to compute StoreMessage's cached_to column cheaply (no MIME/multipart walk needed +// just to cache a header for fast folder-listing display). Returns "" on any parse +// failure or if the header is absent, never an error — this is a display convenience, +// not something delivery should ever fail over. +func extractHeaderValue(raw []byte, name string) string { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return "" + } + return msg.Header.Get(name) +} + // ErrQuotaExceeded is returned by StoreMessage when storing raw would push the // mailbox over its quota. No row, file, or used_bytes change occurs in that case. var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded") @@ -51,7 +66,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message return 0, err } - uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, subject) + uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject) if err != nil { os.Remove(storagePath) return 0, err diff --git a/internal/mailview/mailview.go b/internal/mailview/mailview.go new file mode 100644 index 0000000..70bee41 --- /dev/null +++ b/internal/mailview/mailview.go @@ -0,0 +1,175 @@ +// Package mailview parses a raw RFC822 message into a structure a web UI can render: +// separate plain-text and HTML bodies, plus a flat list of attachments. It exists +// because internal/smtpserver's own MIME walker (parseMessage in attachments.go) is +// unexported, SMTP-inbound-specific, and only concatenates every text/* part into one +// blob — a webmail reader needs to keep text/plain and text/html distinct (so it can +// prefer HTML but still offer a plain-text view) and needs real attachment metadata +// for download links, not just a body string. +package mailview + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "net/mail" + "path/filepath" + "strings" +) + +// Header is the small set of top-level headers a message view needs — never the full +// header block (this isn't a general-purpose header inspector). +type Header struct { + From, To, Cc, Subject, Date, MessageID string +} + +// Attachment is one file extracted from the message, decoded to its real bytes (never +// left as raw base64/quoted-printable text). +type Attachment struct { + Filename string + ContentType string + Data []byte +} + +// Message is the parsed result. TextBody/HTMLBody are independently populated when +// present (e.g. a multipart/alternative body yields both) — never merged — so a +// caller can prefer HTML but still fall back to plain text. +type Message struct { + Header Header + TextBody string + HTMLBody string + Attachments []Attachment +} + +// Parse walks raw's MIME structure (recursing into nested multiparts, e.g. a +// multipart/alternative inside a multipart/mixed) and classifies every leaf part as +// the text body, the HTML body, or an attachment. +func Parse(raw []byte) (*Message, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + m := &Message{Header: Header{ + From: msg.Header.Get("From"), + To: msg.Header.Get("To"), + Cc: msg.Header.Get("Cc"), + Subject: msg.Header.Get("Subject"), + Date: msg.Header.Get("Date"), + MessageID: msg.Header.Get("Message-Id"), + }} + + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if err != nil { + mediaType = "text/plain" + } + if strings.HasPrefix(mediaType, "multipart/") { + if err := walkMultipart(m, msg.Body, params["boundary"]); err != nil { + return nil, err + } + return m, nil + } + + data, _ := io.ReadAll(msg.Body) + data = decodeContentTransferEncoding(msg.Header.Get("Content-Transfer-Encoding"), data) + if mediaType == "text/html" { + m.HTMLBody = string(data) + } else { + m.TextBody = string(data) + } + return m, nil +} + +func walkMultipart(m *Message, r io.Reader, boundary string) error { + if boundary == "" { + return nil + } + mr := multipart.NewReader(r, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + return nil + } + if err != nil { + // Tolerate a malformed trailing part rather than losing everything + // already parsed — a webmail reader should show what it can. + return nil + } + + mediaType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + mediaType = "text/plain" + } + disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition")) + + data, _ := io.ReadAll(part) + data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data) + + if strings.HasPrefix(mediaType, "multipart/") { + walkMultipart(m, bytes.NewReader(data), params["boundary"]) + continue + } + + filename := dispParams["filename"] + if filename == "" { + filename = params["name"] + } + + switch { + case disp == "attachment" || (filename != "" && disp != "inline"): + m.Attachments = append(m.Attachments, Attachment{ + Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, + }) + case mediaType == "text/html": + m.HTMLBody += string(data) + case strings.HasPrefix(mediaType, "text/"): + if m.TextBody != "" { + m.TextBody += "\n" + } + m.TextBody += string(data) + case filename != "": + // Inline non-text part (e.g. an embedded image) with no explicit + // disposition — still worth surfacing as a downloadable attachment + // rather than silently dropping it. + m.Attachments = append(m.Attachments, Attachment{ + Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, + }) + } + } +} + +// contentTypeFor mirrors smtpserver's getContentType: prefer the part's own +// declared type, fall back to extension sniffing for the generic default. +func contentTypeFor(mediaType, filename string) string { + if mediaType != "" && mediaType != "application/octet-stream" { + return mediaType + } + if guessed := mime.TypeByExtension(filepath.Ext(filename)); guessed != "" { + return guessed + } + return "application/octet-stream" +} + +// decodeContentTransferEncoding mirrors smtpserver's identically-named helper: +// mime/multipart.Reader only auto-decodes quoted-printable transparently, never +// base64, so that case needs manual decoding or attachments/HTML bodies come out as +// raw base64 text instead of their real bytes. +func decodeContentTransferEncoding(cte string, data []byte) []byte { + if !strings.EqualFold(strings.TrimSpace(cte), "base64") { + return data + } + cleaned := make([]byte, 0, len(data)) + for _, b := range data { + switch b { + case ' ', '\t', '\r', '\n': + continue + default: + cleaned = append(cleaned, b) + } + } + decoded, err := base64.StdEncoding.DecodeString(string(cleaned)) + if err != nil { + return data + } + return decoded +} diff --git a/internal/mailview/mailview_test.go b/internal/mailview/mailview_test.go new file mode 100644 index 0000000..1499827 --- /dev/null +++ b/internal/mailview/mailview_test.go @@ -0,0 +1,91 @@ +package mailview + +import ( + "strings" + "testing" +) + +func TestParseSimpleTextMessage(t *testing.T) { + raw := "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n\r\nhello there" + m, err := Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if m.Header.From != "a@example.com" || m.Header.Subject != "hi" { + t.Errorf("headers = %+v", m.Header) + } + if m.TextBody != "hello there" { + t.Errorf("TextBody = %q", m.TextBody) + } + if m.HTMLBody != "" || len(m.Attachments) != 0 { + t.Errorf("expected no HTML body or attachments, got HTMLBody=%q attachments=%d", m.HTMLBody, len(m.Attachments)) + } +} + +func TestParseMultipartAlternativeKeepsBothBodies(t *testing.T) { + raw := "" + + "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" + + "Content-Type: multipart/alternative; boundary=\"B\"\r\n\r\n" + + "--B\r\nContent-Type: text/plain\r\n\r\nplain version\r\n" + + "--B\r\nContent-Type: text/html\r\n\r\n

html version

\r\n" + + "--B--\r\n" + m, err := Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(m.TextBody) != "plain version" { + t.Errorf("TextBody = %q", m.TextBody) + } + if strings.TrimSpace(m.HTMLBody) != "

html version

" { + t.Errorf("HTMLBody = %q", m.HTMLBody) + } +} + +func TestParseAttachmentDecodesBase64(t *testing.T) { + raw := "" + + "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" + + "Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n" + + "--B\r\nContent-Type: text/plain\r\n\r\nsee attached\r\n" + + "--B\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"a.txt\"\r\n" + + "Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" + + "--B--\r\n" + m, err := Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if len(m.Attachments) != 1 { + t.Fatalf("got %d attachments, want 1", len(m.Attachments)) + } + if got := string(m.Attachments[0].Data); got != "Hello, world!" { + t.Errorf("attachment data = %q, want decoded base64", got) + } + if m.Attachments[0].Filename != "a.txt" { + t.Errorf("filename = %q", m.Attachments[0].Filename) + } +} + +func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) { + raw := "" + + "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" + + "Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n\r\n" + + "--OUTER\r\nContent-Type: multipart/alternative; boundary=\"INNER\"\r\n\r\n" + + "--INNER\r\nContent-Type: text/plain\r\n\r\nplain body\r\n" + + "--INNER\r\nContent-Type: text/html\r\n\r\n

html body

\r\n" + + "--INNER--\r\n" + + "--OUTER\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\n" + + "attached notes\r\n" + + "--OUTER--\r\n" + m, err := Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(m.TextBody) != "plain body" { + t.Errorf("TextBody = %q", m.TextBody) + } + if strings.TrimSpace(m.HTMLBody) != "

html body

" { + t.Errorf("HTMLBody = %q", m.HTMLBody) + } + if len(m.Attachments) != 1 || m.Attachments[0].Filename != "notes.txt" { + t.Fatalf("attachments = %+v", m.Attachments) + } +} diff --git a/internal/pgp/entity.go b/internal/pgp/entity.go new file mode 100644 index 0000000..830a355 --- /dev/null +++ b/internal/pgp/entity.go @@ -0,0 +1,176 @@ +package pgp + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/textproto" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +// Entity is a MIME entity: its own part-level headers plus its body — deliberately +// the same shape as smime.Entity, so webui's compose/read handlers can pass the same +// value between either package's Encrypt/Decrypt without conversion glue. +type Entity struct { + Headers []string + Body []byte +} + +func (e Entity) bytes() []byte { + var buf bytes.Buffer + for _, h := range e.Headers { + buf.WriteString(h) + buf.WriteString("\r\n") + } + buf.WriteString("\r\n") + buf.Write(e.Body) + return buf.Bytes() +} + +// parseEntity splits raw bytes (headers, a blank line, then body) back into an +// Entity — used to recover the inner MIME entity after Decrypt. +func parseEntity(raw []byte) Entity { + idx := bytes.Index(raw, []byte("\r\n\r\n")) + sep := 4 + if idx < 0 { + idx = bytes.Index(raw, []byte("\n\n")) + sep = 2 + } + if idx < 0 { + return Entity{Body: raw} + } + var headers []string + for _, line := range strings.Split(string(raw[:idx]), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + headers = append(headers, line) + } + return Entity{Headers: headers, Body: raw[idx+sep:]} +} + +func headerValue(headers []string, name string) string { + for _, h := range headers { + if i := strings.Index(h, ":"); i >= 0 && strings.EqualFold(strings.TrimSpace(h[:i]), name) { + return strings.TrimSpace(h[i+1:]) + } + } + return "" +} + +// EncryptEntity wraps entity's bytes as an RFC 3156 PGP/MIME multipart/encrypted +// structure, encrypted to recipients. Pass every recipient's public key, including +// the sender's own, so a copy kept in Sent stays readable — mirrors +// smime.Encrypt's same convention. +func EncryptEntity(entity Entity, recipients []*openpgp.Entity) (Entity, error) { + if len(recipients) == 0 { + return Entity{}, errors.New("pgp: no recipient keys provided") + } + + var armored bytes.Buffer + aw, err := armor.Encode(&armored, "PGP MESSAGE", nil) + if err != nil { + return Entity{}, fmt.Errorf("pgp: encrypt: %w", err) + } + pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig()) + if err != nil { + return Entity{}, fmt.Errorf("pgp: encrypt: %w", err) + } + if _, err := pt.Write(entity.bytes()); err != nil { + return Entity{}, fmt.Errorf("pgp: encrypt: %w", err) + } + if err := pt.Close(); err != nil { + return Entity{}, fmt.Errorf("pgp: encrypt: %w", err) + } + if err := aw.Close(); err != nil { + return Entity{}, fmt.Errorf("pgp: encrypt: %w", err) + } + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + ctrlPart, err := mw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"application/pgp-encrypted"}, + "Content-Transfer-Encoding": {"7bit"}, + }) + if err != nil { + return Entity{}, err + } + if _, err := ctrlPart.Write([]byte("Version: 1\r\n")); err != nil { + return Entity{}, err + } + dataPart, err := mw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {`application/octet-stream; name="encrypted.asc"`}, + "Content-Disposition": {`inline; filename="encrypted.asc"`}, + "Content-Transfer-Encoding": {"7bit"}, + }) + if err != nil { + return Entity{}, err + } + if _, err := dataPart.Write(armored.Bytes()); err != nil { + return Entity{}, err + } + if err := mw.Close(); err != nil { + return Entity{}, err + } + + return Entity{ + Headers: []string{ + fmt.Sprintf(`Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="%s"`, mw.Boundary()), + }, + Body: body.Bytes(), + }, nil +} + +// DecryptEntity reverses EncryptEntity, decrypting with unlockedIdentity (its +// private key material must already be unlocked via UnlockPrivateKey — this +// function never takes a passphrase itself). +func DecryptEntity(entity Entity, unlockedIdentity *openpgp.Entity) (Entity, error) { + ct := headerValue(entity.Headers, "Content-Type") + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil || mediaType != "multipart/encrypted" { + return Entity{}, errors.New("pgp: not a multipart/encrypted message") + } + if !strings.EqualFold(params["protocol"], "application/pgp-encrypted") { + return Entity{}, fmt.Errorf("pgp: unsupported multipart/encrypted protocol %q", params["protocol"]) + } + boundary := params["boundary"] + if boundary == "" { + return Entity{}, errors.New("pgp: missing multipart boundary") + } + + mr := multipart.NewReader(bytes.NewReader(entity.Body), boundary) + // First part is the application/pgp-encrypted control part ("Version: 1") — not + // needed, the actual ciphertext is the second part. + if _, err := mr.NextPart(); err != nil { + return Entity{}, fmt.Errorf("pgp: read control part: %w", err) + } + dataPart, err := mr.NextPart() + if err != nil { + return Entity{}, fmt.Errorf("pgp: read data part: %w", err) + } + armoredCiphertext, err := io.ReadAll(dataPart) + if err != nil { + return Entity{}, err + } + + block, err := armor.Decode(bytes.NewReader(armoredCiphertext)) + if err != nil { + return Entity{}, fmt.Errorf("pgp: invalid armored ciphertext: %w", err) + } + md, err := openpgp.ReadMessage(block.Body, openpgp.EntityList{unlockedIdentity}, nil, defaultConfig()) + if err != nil { + return Entity{}, fmt.Errorf("pgp: decrypt: %w", err) + } + plaintext, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return Entity{}, fmt.Errorf("pgp: decrypt: %w", err) + } + return parseEntity(plaintext), nil +} diff --git a/internal/pgp/identity.go b/internal/pgp/identity.go new file mode 100644 index 0000000..45dbfb3 --- /dev/null +++ b/internal/pgp/identity.go @@ -0,0 +1,175 @@ +// Package pgp implements OpenPGP key generation/import and message encryption for +// the webmail client's PGP encryption feature. Deliberately encryption-only: this +// codebase uses S/MIME (internal/smime) for signing, PGP only for confidentiality +// (an explicit design split) — no signature generation or verification code lives +// here. +// +// Uses github.com/ProtonMail/go-crypto/openpgp, the actively maintained replacement +// for the deprecated (and explicitly "unsafe by design", per its own doc comment) +// golang.org/x/crypto/openpgp. +package pgp + +import ( + "bytes" + "errors" + "fmt" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +// defaultConfig pins AES-256 (the library defaults to AES-128) — same "always pick +// the strong option explicitly" posture as internal/smime. RSA-2048 (the library's +// own zero-value default when Config.RSABits is unset) matches this codebase's +// existing key-size convention (internal/smime, internal/tlsutil both use RSA-2048). +func defaultConfig() *packet.Config { + return &packet.Config{DefaultCipher: packet.CipherAES256} +} + +// GenerateKeyPair creates a fresh RSA-2048 OpenPGP keypair for email, protects the +// private key material with passphrase (the library's own native S2K passphrase +// protection, part of the OpenPGP private-key packet format itself — no separate +// wrapping layer needed, unlike internal/smime's hand-rolled scrypt+AES-GCM), and +// returns both halves ASCII-armored. +func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) { + cfg := defaultConfig() + entity, err := openpgp.NewEntity(email, "", email, cfg) + if err != nil { + return nil, nil, fmt.Errorf("pgp: generate key: %w", err) + } + if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil { + return nil, nil, fmt.Errorf("pgp: protect private key: %w", err) + } + if publicArmor, err = serializePublic(entity); err != nil { + return nil, nil, err + } + if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil { + return nil, nil, err + } + return publicArmor, privateArmor, nil +} + +// ImportPrivateKey parses an ASCII-armored private key export (e.g. from `gpg +// --export-secret-keys --armor`). If it isn't already passphrase-protected, +// passphrase is used to protect it before storing (same posture as generate — never +// store an unprotected private key). If it's already protected, passphrase must be +// the one that already unlocks it — verified here (by actually unlocking it) so a +// wrong passphrase is caught at import time rather than silently producing a +// permanently unusable stored key. +func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) { + entity, err := readArmoredEntity(armoredData) + if err != nil { + return nil, nil, err + } + if entity.PrivateKey == nil { + return nil, nil, errors.New("pgp: no private key found in the uploaded file") + } + cfg := defaultConfig() + if entity.PrivateKey.Encrypted { + if err := UnlockPrivateKey(entity, passphrase); err != nil { + return nil, nil, fmt.Errorf("pgp: wrong passphrase for the imported key: %w", err) + } + } + // Re-encrypt (or encrypt for the first time) with passphrase — verified live + // that decrypting and re-encrypting the same in-memory Entity, then serializing + // without signing, round-trips correctly. + if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil { + return nil, nil, fmt.Errorf("pgp: protect private key: %w", err) + } + if publicArmor, err = serializePublic(entity); err != nil { + return nil, nil, err + } + if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil { + return nil, nil, err + } + return publicArmor, privateArmor, nil +} + +// ParsePublicKey parses an ASCII-armored public key block — used for a contact's +// key, added by hand (PGP has no signature here to auto-capture a contact from the +// way S/MIME does). +func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) { + return readArmoredEntity(armoredData) +} + +// ParsePrivateKey parses a stored (already passphrase-protected) armored private +// key back into an Entity, still locked — call UnlockPrivateKey with the passphrase +// before using it to decrypt anything. +func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) { + return readArmoredEntity(armoredData) +} + +// UnlockPrivateKey decrypts entity's primary private key AND every subkey's private +// key with passphrase — the actual encryption-capable key lives on a subkey in +// modern OpenPGP layout (confirmed via a live round-trip test), so both must be +// unlocked before Decrypt can use entity as a recipient key. +func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error { + if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { + if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil { + return fmt.Errorf("pgp: %w", err) + } + } + for _, sk := range entity.Subkeys { + if sk.PrivateKey != nil && sk.PrivateKey.Encrypted { + if err := sk.PrivateKey.Decrypt([]byte(passphrase)); err != nil { + return fmt.Errorf("pgp: %w", err) + } + } + } + return nil +} + +// Fingerprint returns entity's primary key fingerprint as uppercase hex, for +// display — distinguishing keys beyond just their user-supplied label. +func Fingerprint(entity *openpgp.Entity) string { + return fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint) +} + +func readArmoredEntity(armoredData []byte) (*openpgp.Entity, error) { + block, err := armor.Decode(bytes.NewReader(armoredData)) + if err != nil { + return nil, fmt.Errorf("pgp: invalid armored data: %w", err) + } + entity, err := openpgp.ReadEntity(packet.NewReader(block.Body)) + if err != nil { + return nil, fmt.Errorf("pgp: parse key: %w", err) + } + return entity, nil +} + +func serializePublic(entity *openpgp.Entity) ([]byte, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + if err != nil { + return nil, err + } + if err := entity.Serialize(w); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// serializePrivateWithoutSigning uses SerializePrivateWithoutSigning, not +// SerializePrivate — the latter re-signs identities/subkeys using the private key +// as a crypto.Signer, which panics once the key material is encrypted (confirmed +// via a live round-trip test; SerializePrivate is only safe to call before +// EncryptPrivateKeys, which isn't a option here since every caller wants the +// already-protected key serialized). +func serializePrivateWithoutSigning(entity *openpgp.Entity, cfg *packet.Config) ([]byte, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return nil, err + } + if err := entity.SerializePrivateWithoutSigning(w, cfg); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/pgp/pgp_test.go b/internal/pgp/pgp_test.go new file mode 100644 index 0000000..3a2fcec --- /dev/null +++ b/internal/pgp/pgp_test.go @@ -0,0 +1,248 @@ +package pgp + +import ( + "bytes" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +// serializeUnencryptedPrivate serializes an as-yet-unprotected private key — only +// safe to call before EncryptPrivateKeys (see the gotcha documented on +// serializePrivateWithoutSigning in identity.go). No production code path needs +// this (every stored key goes through EncryptPrivateKeys first); it exists here +// purely to simulate a genuinely unprotected "gpg --export-secret-keys" output for +// TestImportPrivateKeyUnencrypted. +func serializeUnencryptedPrivate(entity *openpgp.Entity) ([]byte, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return nil, err + } + if err := entity.SerializePrivate(w, nil); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func testEntity() Entity { + return Entity{ + Headers: []string{"Content-Type: text/plain; charset=utf-8"}, + Body: []byte("hello world\r\nsecond line\r\n"), + } +} + +func TestGenerateEncryptDecryptRoundTrip(t *testing.T) { + pubPEM, privPEM, err := GenerateKeyPair("alice@example.com", "correct horse battery staple") + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + if bytes.Contains(privPEM, []byte("correct horse")) { + t.Fatal("stored private key armor should not contain the plaintext passphrase") + } + + recipient, err := ParsePublicKey(pubPEM) + if err != nil { + t.Fatalf("ParsePublicKey: %v", err) + } + + orig := testEntity() + encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient}) + if err != nil { + t.Fatalf("EncryptEntity: %v", err) + } + if ct := headerValue(encrypted.Headers, "Content-Type"); ct == "" { + t.Fatal("expected a Content-Type header on the encrypted entity") + } + if bytes.Contains(encrypted.Body, orig.Body) { + t.Fatal("encrypted body should not contain the plaintext") + } + + identity, err := ParsePrivateKey(privPEM) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if err := UnlockPrivateKey(identity, "correct horse battery staple"); err != nil { + t.Fatalf("UnlockPrivateKey: %v", err) + } + + decrypted, err := DecryptEntity(encrypted, identity) + if err != nil { + t.Fatalf("DecryptEntity: %v", err) + } + if !bytes.Equal(decrypted.Body, orig.Body) { + t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body) + } + if headerValue(decrypted.Headers, "Content-Type") != headerValue(orig.Headers, "Content-Type") { + t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers) + } +} + +func TestUnlockPrivateKeyWrongPassphraseFails(t *testing.T) { + _, privPEM, err := GenerateKeyPair("alice@example.com", "right passphrase") + if err != nil { + t.Fatal(err) + } + identity, err := ParsePrivateKey(privPEM) + if err != nil { + t.Fatal(err) + } + if err := UnlockPrivateKey(identity, "wrong passphrase"); err == nil { + t.Fatal("expected the wrong passphrase to fail") + } +} + +func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) { + senderPub, senderPriv, err := GenerateKeyPair("sender@example.com", "sender pass") + if err != nil { + t.Fatal(err) + } + recipPub, recipPriv, err := GenerateKeyPair("recipient@example.com", "recipient pass") + if err != nil { + t.Fatal(err) + } + senderPubEntity, _ := ParsePublicKey(senderPub) + recipPubEntity, _ := ParsePublicKey(recipPub) + + orig := testEntity() + encrypted, err := EncryptEntity(orig, []*openpgp.Entity{senderPubEntity, recipPubEntity}) + if err != nil { + t.Fatal(err) + } + + senderIdentity, _ := ParsePrivateKey(senderPriv) + if err := UnlockPrivateKey(senderIdentity, "sender pass"); err != nil { + t.Fatal(err) + } + senderCopy, err := DecryptEntity(encrypted, senderIdentity) + if err != nil { + t.Fatalf("sender DecryptEntity: %v", err) + } + if !bytes.Equal(senderCopy.Body, orig.Body) { + t.Fatal("sender's own copy did not decrypt to the original body") + } + + recipIdentity, _ := ParsePrivateKey(recipPriv) + if err := UnlockPrivateKey(recipIdentity, "recipient pass"); err != nil { + t.Fatal(err) + } + recipCopy, err := DecryptEntity(encrypted, recipIdentity) + if err != nil { + t.Fatalf("recipient DecryptEntity: %v", err) + } + if !bytes.Equal(recipCopy.Body, orig.Body) { + t.Fatal("recipient's copy did not decrypt to the original body") + } +} + +func TestImportPrivateKeyUnencrypted(t *testing.T) { + // Simulate a raw, not-yet-passphrase-protected export by generating a key and + // serializing it before EncryptPrivateKeys is ever called. + entity, err := openpgp.NewEntity("bob@example.com", "", "bob@example.com", defaultConfig()) + if err != nil { + t.Fatal(err) + } + unprotectedArmor, err := serializeUnencryptedPrivate(entity) + if err != nil { + t.Fatal(err) + } + + pubPEM, privPEM, err := ImportPrivateKey(unprotectedArmor, "new passphrase") + if err != nil { + t.Fatalf("ImportPrivateKey: %v", err) + } + + identity, err := ParsePrivateKey(privPEM) + if err != nil { + t.Fatal(err) + } + if !identity.PrivateKey.Encrypted { + t.Fatal("expected the imported key to be encrypted after import") + } + if err := UnlockPrivateKey(identity, "new passphrase"); err != nil { + t.Fatalf("expected the new passphrase to unlock the imported key: %v", err) + } + + recipient, err := ParsePublicKey(pubPEM) + if err != nil { + t.Fatal(err) + } + orig := testEntity() + encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient}) + if err != nil { + t.Fatal(err) + } + decrypted, err := DecryptEntity(encrypted, identity) + if err != nil { + t.Fatalf("DecryptEntity after import: %v", err) + } + if !bytes.Equal(decrypted.Body, orig.Body) { + t.Fatal("round trip through an imported unencrypted key failed") + } +} + +func TestImportPrivateKeyAlreadyEncrypted(t *testing.T) { + _, existingArmor, err := GenerateKeyPair("carol@example.com", "original passphrase") + if err != nil { + t.Fatal(err) + } + + if _, _, err := ImportPrivateKey(existingArmor, "wrong passphrase"); err == nil { + t.Fatal("expected import with the wrong passphrase for an already-encrypted key to fail") + } + + pubPEM, privPEM, err := ImportPrivateKey(existingArmor, "original passphrase") + if err != nil { + t.Fatalf("ImportPrivateKey with the correct passphrase: %v", err) + } + identity, err := ParsePrivateKey(privPEM) + if err != nil { + t.Fatal(err) + } + if err := UnlockPrivateKey(identity, "original passphrase"); err != nil { + t.Fatalf("expected the original passphrase to still unlock after re-import: %v", err) + } + + recipient, err := ParsePublicKey(pubPEM) + if err != nil { + t.Fatal(err) + } + orig := testEntity() + encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient}) + if err != nil { + t.Fatal(err) + } + decrypted, err := DecryptEntity(encrypted, identity) + if err != nil { + t.Fatalf("DecryptEntity after re-import: %v", err) + } + if !bytes.Equal(decrypted.Body, orig.Body) { + t.Fatal("round trip through a re-imported already-encrypted key failed") + } +} + +func TestFingerprintIsStableAndNonEmpty(t *testing.T) { + pubPEM, _, err := GenerateKeyPair("alice@example.com", "pass") + if err != nil { + t.Fatal(err) + } + entity, err := ParsePublicKey(pubPEM) + if err != nil { + t.Fatal(err) + } + fp := Fingerprint(entity) + if len(fp) == 0 { + t.Fatal("expected a non-empty fingerprint") + } + entity2, err := ParsePublicKey(pubPEM) + if err != nil { + t.Fatal(err) + } + if Fingerprint(entity2) != fp { + t.Fatal("expected the fingerprint to be stable across re-parses of the same key") + } +} diff --git a/internal/smime/encrypt.go b/internal/smime/encrypt.go new file mode 100644 index 0000000..d1d2b02 --- /dev/null +++ b/internal/smime/encrypt.go @@ -0,0 +1,64 @@ +package smime + +import ( + "crypto" + "crypto/x509" + "errors" + "fmt" + "mime" + "strings" + + "go.mozilla.org/pkcs7" +) + +// Encrypt wraps entity's canonical bytes as CMS EnvelopedData addressed to +// recipients, per RFC 8551 application/pkcs7-mime; smime-type=enveloped-data. Pass +// every recipient's certificate, including the sender's own, so a copy kept in Sent +// stays readable. +func Encrypt(entity Entity, recipients []*x509.Certificate) (Entity, error) { + if len(recipients) == 0 { + return Entity{}, errors.New("smime: no recipient certificates provided") + } + envelopedDER, err := pkcs7.Encrypt(entity.bytes(), recipients) + if err != nil { + return Entity{}, fmt.Errorf("smime: encrypt: %w", err) + } + return Entity{ + Headers: []string{ + `Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: attachment; filename="smime.p7m"`, + }, + Body: []byte(wrapBase64(envelopedDER)), + }, nil +} + +// Decrypt reverses Encrypt, returning the inner MIME entity that was originally +// wrapped. +func Decrypt(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) { + ct := HeaderValue(entity.Headers, "Content-Type") + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil || mediaType != "application/pkcs7-mime" { + return Entity{}, errors.New("smime: not an application/pkcs7-mime message") + } + if st := params["smime-type"]; st != "" && !strings.EqualFold(st, "enveloped-data") { + return Entity{}, fmt.Errorf("smime: unsupported smime-type %q", st) + } + + raw := entity.Body + if isBase64CTE(HeaderValue(entity.Headers, "Content-Transfer-Encoding")) { + if raw, err = decodeBase64(entity.Body); err != nil { + return Entity{}, fmt.Errorf("smime: decode envelope: %w", err) + } + } + + p7, err := pkcs7.Parse(raw) + if err != nil { + return Entity{}, fmt.Errorf("smime: parse envelope: %w", err) + } + plaintext, err := p7.Decrypt(cert, key) + if err != nil { + return Entity{}, fmt.Errorf("smime: decrypt: %w", err) + } + return parseEntity(plaintext) +} diff --git a/internal/smime/entity.go b/internal/smime/entity.go new file mode 100644 index 0000000..3422af3 --- /dev/null +++ b/internal/smime/entity.go @@ -0,0 +1,151 @@ +package smime + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "strings" +) + +// Entity is a MIME entity: its own part-level headers (Content-Type, +// Content-Transfer-Encoding, Content-Disposition — never envelope headers like +// From/To/Subject/Date) plus its body. Sign/Encrypt/Decrypt/VerifySigned all operate +// on an Entity, not a flat raw RFC822 message — the caller (webui's compose/read +// handlers) is responsible for keeping envelope headers separate, since S/MIME only +// ever transforms the message body's own MIME entity, never the envelope. +type Entity struct { + Headers []string + Body []byte +} + +// bytes renders the entity as it would appear on the wire: headers, a blank line, +// then the body normalized to CRLF line endings — MIME's canonical form, which is +// what gets hashed/signed/encrypted. Both Sign and Encrypt must operate on exactly +// this rendering so a receiving client's own canonicalization matches ours. +func (e Entity) bytes() []byte { + var buf bytes.Buffer + for _, h := range e.Headers { + buf.WriteString(h) + buf.WriteString("\r\n") + } + buf.WriteString("\r\n") + buf.Write(toCRLF(e.Body)) + return buf.Bytes() +} + +// parseEntity splits raw bytes (headers, a blank line, then body) back into an +// Entity — used to recover the inner MIME entity after Decrypt or the signed part +// after VerifySigned, both of which hand back a full "headers+body" byte blob. +func parseEntity(raw []byte) (Entity, error) { + idx := bytes.Index(raw, []byte("\r\n\r\n")) + sep := 4 + if idx < 0 { + idx = bytes.Index(raw, []byte("\n\n")) + sep = 2 + } + if idx < 0 { + return Entity{Body: raw}, nil + } + var headers []string + for _, line := range strings.Split(string(raw[:idx]), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + headers = append(headers, line) + } + return Entity{Headers: headers, Body: raw[idx+sep:]}, nil +} + +// toCRLF normalizes line endings to CRLF — first collapsing any existing CRLF to a +// bare LF so a mixed or already-CRLF input doesn't end up double-terminated. +func toCRLF(b []byte) []byte { + b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) + return bytes.ReplaceAll(b, []byte("\n"), []byte("\r\n")) +} + +// HeaderValue is a case-insensitive lookup over a MIME entity's header lines — +// exported so callers outside this package (e.g. webui's read-integration, which +// must inspect a message's Content-Type before deciding whether to unwrap it) don't +// need to reimplement it. +func HeaderValue(headers []string, name string) string { + for _, h := range headers { + idx := strings.Index(h, ":") + if idx < 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(h[:idx]), name) { + return strings.TrimSpace(h[idx+1:]) + } + } + return "" +} + +func isBase64CTE(cte string) bool { + return strings.EqualFold(strings.TrimSpace(cte), "base64") +} + +func decodeBase64(data []byte) ([]byte, error) { + return base64.StdEncoding.DecodeString(stripWhitespace(string(data))) +} + +func stripWhitespace(s string) string { + var b strings.Builder + for _, r := range s { + switch r { + case ' ', '\t', '\r', '\n': + continue + default: + b.WriteRune(r) + } + } + return b.String() +} + +func newBoundary() string { + b := make([]byte, 16) + rand.Read(b) + return "----=_SMIME_" + hex.EncodeToString(b) +} + +// wrapBase64 base64-encodes data at the RFC 2045-recommended 76 characters per line — +// cosmetic (a decoder doesn't care), but matches what every real MTA/MUA produces. +func wrapBase64(data []byte) string { + encoded := base64.StdEncoding.EncodeToString(data) + var b strings.Builder + for i := 0; i < len(encoded); i += 76 { + end := min(i+76, len(encoded)) + b.WriteString(encoded[i:end]) + b.WriteString("\r\n") + } + return strings.TrimRight(b.String(), "\r\n") +} + +// splitMultipartRaw extracts each part's *exact* original bytes between boundary +// delimiters — deliberately not using mime/multipart.Reader, whose Part API parses +// headers away from the raw body and would require re-serializing them to recover +// signable bytes. A detached S/MIME signature covers the literal octets of the +// signed part (RFC 8551 §3.4.3), so reconstruction-from-parsed-headers risks a +// byte-for-byte mismatch (header order, casing, whitespace) that breaks verification +// even for semantically-identical content. Real S/MIME implementations extract raw +// byte ranges for exactly this reason. +func splitMultipartRaw(body []byte, boundary string) ([][]byte, error) { + delim := []byte("--" + boundary) + segments := bytes.Split(body, delim) + if len(segments) < 3 { + return nil, errors.New("smime: malformed multipart body") + } + // segments[0] is the preamble (ignored); the last segment starts with "--" (the + // closing delimiter) and anything after is the epilogue (ignored). Everything in + // between is one part, each still wrapped in the CRLF that separated it from its + // boundary line. + parts := make([][]byte, 0, len(segments)-2) + for _, seg := range segments[1 : len(segments)-1] { + seg = bytes.TrimPrefix(seg, []byte("\r\n")) + seg = bytes.TrimSuffix(seg, []byte("\r\n")) + parts = append(parts, seg) + } + return parts, nil +} diff --git a/internal/smime/identity.go b/internal/smime/identity.go new file mode 100644 index 0000000..6df4c5c --- /dev/null +++ b/internal/smime/identity.go @@ -0,0 +1,129 @@ +// Package smime implements S/MIME certificate-based email signing and encryption: +// generating or importing a mailbox's own identity (certificate + private key), +// signing outbound mail (RFC 8551 multipart/signed, detached CMS SignedData), +// verifying a signature, encrypting outbound mail (application/pkcs7-mime, CMS +// EnvelopedData), and decrypting it again. +// +// This package is deliberately certificate-chain-agnostic: it does not validate a +// certificate against any CA trust store. A verified signature here means "this +// message was cryptographically signed by the private key matching this exact +// certificate," not "this certificate is trusted by a PKI" — the same posture this +// codebase's own self-signed TLS certificate already has. Callers that want to +// display a warning for unrecognized signers should compare the signer's certificate +// against their own address book (see the mailbox_smime_contacts table in +// internal/db), not chain validation. +package smime + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" + + "go.mozilla.org/pkcs7" + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +func init() { + // go.mozilla.org/pkcs7 defaults ContentEncryptionAlgorithm to legacy DES-CBC for + // backward compatibility with old clients — AES-256-GCM is the only acceptable + // choice for anything generated here. + pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM +} + +// DefaultValidity mirrors a typical S/MIME certificate lifetime (1 year), matching +// what most CAs issue for individual email certificates. +const DefaultValidity = 365 * 24 * time.Hour + +// GenerateSelfSigned creates a fresh RSA-2048 keypair and a self-signed certificate +// scoped to email — same key size internal/tlsutil already uses for the server's own +// TLS certificate. KeyUsage/ExtKeyUsage/EmailAddresses are set per RFC 8551 so +// mainstream mail clients recognize it as a valid S/MIME certificate. +func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, fmt.Errorf("smime: generate key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("smime: generate serial: %w", err) + } + subject := pkix.Name{CommonName: email} + template := x509.Certificate{ + SerialNumber: serial, + Subject: subject, + Issuer: subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(validity), + SignatureAlgorithm: x509.SHA256WithRSA, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}, + EmailAddresses: []string{email}, + BasicConstraintsValid: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, fmt.Errorf("smime: create certificate: %w", err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("smime: marshal private key: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} + +// ImportPKCS12 parses a .p12/.pfx bundle — the usual export format from a CA or +// another mail client — into a certificate + private key. Only RSA keys are +// supported (the only key type go.mozilla.org/pkcs7's encrypt/decrypt operations +// actually support for key transport). +func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) { + priv, cert, err := pkcs12.Decode(data, password) + if err != nil { + return nil, nil, fmt.Errorf("smime: decode PKCS#12: %w", err) + } + rsaKey, ok := priv.(*rsa.PrivateKey) + if !ok { + return nil, nil, errors.New("smime: only RSA keys are supported") + } + keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey) + if err != nil { + return nil, nil, fmt.Errorf("smime: marshal private key: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} + +// ParseCertPEM decodes a stored certificate back into a usable *x509.Certificate. +func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, errors.New("smime: invalid certificate PEM") + } + return x509.ParseCertificate(block.Bytes) +} + +// ParseKeyPEM decodes a stored private key back into a usable crypto.PrivateKey. +// Tries PKCS#8 first (what GenerateSelfSigned/ImportPKCS12 both produce), falling +// back to PKCS#1 for a hand-imported PEM that used the older format. +func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) { + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, errors.New("smime: invalid private key PEM") + } + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + return nil, errors.New("smime: unrecognized private key format") +} diff --git a/internal/smime/sign.go b/internal/smime/sign.go new file mode 100644 index 0000000..d01a1b6 --- /dev/null +++ b/internal/smime/sign.go @@ -0,0 +1,99 @@ +package smime + +import ( + "crypto" + "crypto/x509" + "errors" + "fmt" + "mime" + + "go.mozilla.org/pkcs7" +) + +// Sign wraps entity in RFC 8551 multipart/signed: part 1 is the entity's own +// canonical bytes (unmodified — this is what the signature covers), part 2 is a +// detached CMS SignedData over those same bytes. +func Sign(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) { + content := entity.bytes() + + sd, err := pkcs7.NewSignedData(content) + if err != nil { + return Entity{}, fmt.Errorf("smime: sign: %w", err) + } + sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256) + if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil { + return Entity{}, fmt.Errorf("smime: sign: %w", err) + } + sd.Detach() + sigDER, err := sd.Finish() + if err != nil { + return Entity{}, fmt.Errorf("smime: sign: %w", err) + } + + boundary := newBoundary() + body := make([]byte, 0, len(content)+len(sigDER)*2) + body = append(body, []byte("--"+boundary+"\r\n")...) + body = append(body, content...) + body = append(body, []byte("\r\n--"+boundary+"\r\n")...) + body = append(body, []byte("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")...) + body = append(body, []byte("Content-Transfer-Encoding: base64\r\n")...) + body = append(body, []byte("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")...) + body = append(body, []byte(wrapBase64(sigDER))...) + body = append(body, []byte("\r\n--"+boundary+"--\r\n")...) + + return Entity{ + Headers: []string{ + fmt.Sprintf(`Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="%s"`, boundary), + }, + Body: body, + }, nil +} + +// VerifySigned parses a multipart/signed entity produced by Sign (or any RFC +// 8551-compliant sender), checks the detached signature against the exact original +// bytes of part 1, and returns that inner entity plus the signer's certificate. On a +// signature mismatch it still returns the inner entity — so a tampered or +// unverifiable message can be shown with a warning rather than hidden — alongside a +// non-nil error and a nil signer. +func VerifySigned(entity Entity) (inner Entity, signer *x509.Certificate, err error) { + ct := HeaderValue(entity.Headers, "Content-Type") + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil || mediaType != "multipart/signed" { + return Entity{}, nil, errors.New("smime: not a multipart/signed message") + } + boundary := params["boundary"] + if boundary == "" { + return Entity{}, nil, errors.New("smime: missing multipart boundary") + } + parts, err := splitMultipartRaw(entity.Body, boundary) + if err != nil || len(parts) < 2 { + return Entity{}, nil, errors.New("smime: malformed signed message") + } + signedContent := parts[0] + + sigPart, err := parseEntity(parts[1]) + if err != nil { + return Entity{}, nil, err + } + sigDER := sigPart.Body + if isBase64CTE(HeaderValue(sigPart.Headers, "Content-Transfer-Encoding")) { + if sigDER, err = decodeBase64(sigPart.Body); err != nil { + return Entity{}, nil, fmt.Errorf("smime: decode signature: %w", err) + } + } + + p7, err := pkcs7.Parse(sigDER) + if err != nil { + return Entity{}, nil, fmt.Errorf("smime: parse signature: %w", err) + } + p7.Content = signedContent + + inner, perr := parseEntity(signedContent) + if perr != nil { + return Entity{}, nil, perr + } + if err := p7.Verify(); err != nil { + return inner, nil, fmt.Errorf("smime: signature verification failed: %w", err) + } + return inner, p7.GetOnlySigner(), nil +} diff --git a/internal/smime/smime_test.go b/internal/smime/smime_test.go new file mode 100644 index 0000000..d90cdb5 --- /dev/null +++ b/internal/smime/smime_test.go @@ -0,0 +1,215 @@ +package smime + +import ( + "bytes" + "crypto" + "crypto/x509" + "strings" + "testing" +) + +func testIdentity(t *testing.T, email string) (*x509.Certificate, crypto.PrivateKey) { + t.Helper() + certPEM, keyPEM, err := GenerateSelfSigned(email, DefaultValidity) + if err != nil { + t.Fatalf("GenerateSelfSigned: %v", err) + } + cert, err := ParseCertPEM(certPEM) + if err != nil { + t.Fatalf("ParseCertPEM: %v", err) + } + key, err := ParseKeyPEM(keyPEM) + if err != nil { + t.Fatalf("ParseKeyPEM: %v", err) + } + return cert, key +} + +// testEntity uses CRLF line endings already, since Sign/Encrypt canonicalize the +// body to CRLF (MIME's wire form) before signing/encrypting — a round trip through +// either normalizes bare LF to CRLF, so tests compare against the canonical form. +func testEntity() Entity { + return Entity{ + Headers: []string{"Content-Type: text/plain; charset=utf-8"}, + Body: []byte("hello world\r\nsecond line\r\n"), + } +} + +func TestSignVerifyRoundTrip(t *testing.T) { + cert, key := testIdentity(t, "alice@example.com") + orig := testEntity() + + signed, err := Sign(orig, cert, key) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if ct := HeaderValue(signed.Headers, "Content-Type"); !strings.HasPrefix(ct, "multipart/signed") { + t.Fatalf("unexpected Content-Type: %q", ct) + } + + inner, signer, err := VerifySigned(signed) + if err != nil { + t.Fatalf("VerifySigned: %v", err) + } + if signer == nil || signer.Subject.CommonName != "alice@example.com" { + t.Fatalf("unexpected signer: %+v", signer) + } + if !bytes.Equal(inner.Body, orig.Body) { + t.Fatalf("body mismatch: got %q want %q", inner.Body, orig.Body) + } + if HeaderValue(inner.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") { + t.Fatalf("header mismatch: got %v want %v", inner.Headers, orig.Headers) + } +} + +func TestVerifySignedDetectsTampering(t *testing.T) { + cert, key := testIdentity(t, "alice@example.com") + signed, err := Sign(testEntity(), cert, key) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + tampered := string(signed.Body) + tampered = strings.Replace(tampered, "hello world", "hello WORLD", 1) + signed.Body = []byte(tampered) + + inner, signer, err := VerifySigned(signed) + if err == nil { + t.Fatal("expected verification error for tampered content, got nil") + } + if signer != nil { + t.Fatalf("expected nil signer on failed verification, got %+v", signer) + } + // The tampered body should still come back for display purposes even though + // verification failed. + if !bytes.Contains(inner.Body, []byte("hello WORLD")) { + t.Fatalf("expected tampered body returned alongside the error, got %q", inner.Body) + } +} + +func TestVerifySignedWrongSignerCert(t *testing.T) { + cert, key := testIdentity(t, "alice@example.com") + other, _ := testIdentity(t, "mallory@example.com") + + signed, err := Sign(testEntity(), cert, key) + if err != nil { + t.Fatalf("Sign: %v", err) + } + _, signer, err := VerifySigned(signed) + if err != nil { + t.Fatalf("VerifySigned: %v", err) + } + if signer.Subject.CommonName == other.Subject.CommonName { + t.Fatal("signer should not match an unrelated certificate") + } +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + cert, key := testIdentity(t, "bob@example.com") + orig := testEntity() + + encrypted, err := Encrypt(orig, []*x509.Certificate{cert}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if ct := HeaderValue(encrypted.Headers, "Content-Type"); !strings.HasPrefix(ct, "application/pkcs7-mime") { + t.Fatalf("unexpected Content-Type: %q", ct) + } + if bytes.Contains(encrypted.Body, orig.Body) { + t.Fatal("encrypted body should not contain the plaintext") + } + + decrypted, err := Decrypt(encrypted, cert, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(decrypted.Body, orig.Body) { + t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body) + } + if HeaderValue(decrypted.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") { + t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers) + } +} + +func TestDecryptWrongKeyFails(t *testing.T) { + cert, _ := testIdentity(t, "bob@example.com") + otherCert, otherKey := testIdentity(t, "mallory@example.com") + + encrypted, err := Encrypt(testEntity(), []*x509.Certificate{cert}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, err := Decrypt(encrypted, otherCert, otherKey); err == nil { + t.Fatal("expected decryption with the wrong key to fail") + } +} + +func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) { + senderCert, senderKey := testIdentity(t, "alice@example.com") + recipCert, recipKey := testIdentity(t, "bob@example.com") + orig := testEntity() + + encrypted, err := Encrypt(orig, []*x509.Certificate{senderCert, recipCert}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + senderCopy, err := Decrypt(encrypted, senderCert, senderKey) + if err != nil { + t.Fatalf("sender Decrypt: %v", err) + } + if !bytes.Equal(senderCopy.Body, orig.Body) { + t.Fatal("sender's own copy did not decrypt to the original body") + } + + recipCopy, err := Decrypt(encrypted, recipCert, recipKey) + if err != nil { + t.Fatalf("recipient Decrypt: %v", err) + } + if !bytes.Equal(recipCopy.Body, orig.Body) { + t.Fatal("recipient's copy did not decrypt to the original body") + } +} + +// TestSignThenEncryptNestedRoundTrip covers the "sign and encrypt" compose option: +// the plaintext is signed, then the whole signed entity is encrypted (opaque +// nesting), matching how webui's compose handler applies both transforms together. +func TestSignThenEncryptNestedRoundTrip(t *testing.T) { + senderCert, senderKey := testIdentity(t, "alice@example.com") + recipCert, recipKey := testIdentity(t, "bob@example.com") + orig := testEntity() + + signed, err := Sign(orig, senderCert, senderKey) + if err != nil { + t.Fatalf("Sign: %v", err) + } + encrypted, err := Encrypt(signed, []*x509.Certificate{recipCert}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + decrypted, err := Decrypt(encrypted, recipCert, recipKey) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !strings.HasPrefix(HeaderValue(decrypted.Headers, "Content-Type"), "multipart/signed") { + t.Fatalf("expected the decrypted layer to still be multipart/signed, got %q", HeaderValue(decrypted.Headers, "Content-Type")) + } + + inner, signer, err := VerifySigned(decrypted) + if err != nil { + t.Fatalf("VerifySigned: %v", err) + } + if signer.Subject.CommonName != "alice@example.com" { + t.Fatalf("unexpected signer: %+v", signer) + } + if !bytes.Equal(inner.Body, orig.Body) { + t.Fatalf("body mismatch after unwrapping both layers: got %q want %q", inner.Body, orig.Body) + } +} + +func TestImportPKCS12RejectsBadPassword(t *testing.T) { + if _, _, err := ImportPKCS12([]byte("not a real pkcs12 file"), "whatever"); err == nil { + t.Fatal("expected an error decoding garbage PKCS#12 data") + } +} diff --git a/internal/smtpserver/abuseguard_integration_test.go b/internal/smtpserver/abuseguard_integration_test.go new file mode 100644 index 0000000..5414eaf --- /dev/null +++ b/internal/smtpserver/abuseguard_integration_test.go @@ -0,0 +1,42 @@ +package smtpserver + +import ( + "net/smtp" + "testing" +) + +// TestRepeatedFailedAuthBlacklistsIP is the live-flow check for the abuseguard wiring +// (see auth.go's authenticate): enough real failed AUTH PLAIN attempts over real TCP +// against a real smtpserver.Backend should land the source IP in esrv_ip_blacklist, +// exactly as internal/abuseguard's own unit tests confirm in isolation — this confirms +// the actual auth.go call site is wired up, not just the abuseguard package itself. +func TestRepeatedFailedAuthBlacklistsIP(t *testing.T) { + backend := newTestBackend(t) + sec, _ := backend.Cfg.NewSection("Security") + sec.NewKey("abuse_detection_enabled", "true") + sec.NewKey("abuse_failure_threshold", "3") + sec.NewKey("abuse_detection_window_minutes", "10") + sec.NewKey("abuse_blacklist_base_hours", "12") + sec.NewKey("abuse_blacklist_max_hours", "168") + + addr := startTestServer(t, backend) + + for i := 0; i < 3; i++ { + c, err := smtp.Dial(addr) + if err != nil { + t.Fatal(err) + } + if authErr := c.Auth(smtp.PlainAuth("", "test@example.com", "wrongpassword", "127.0.0.1")); authErr == nil { + t.Fatal("expected auth failure") + } + c.Close() + } + + blocked, err := backend.DB.IsIPBlacklisted("127.0.0.1") + if err != nil { + t.Fatal(err) + } + if !blocked { + t.Fatal("expected 127.0.0.1 to be blacklisted after 3 failed AUTH attempts (threshold=3)") + } +} diff --git a/internal/smtpserver/auth.go b/internal/smtpserver/auth.go index 5a38882..06b31f9 100644 --- a/internal/smtpserver/auth.go +++ b/internal/smtpserver/auth.go @@ -6,6 +6,7 @@ import ( "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" + "mailgoserver/internal/abuseguard" "mailgoserver/internal/db" ) @@ -76,6 +77,7 @@ func (s *Session) authenticate(username, password string) error { if err != nil { s.backend.Logger.Error("Authentication error: %v", err) _ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err)) + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(451, "Internal server error") } if sender != nil && db.CheckPassword(password, sender.PasswordHash) { @@ -91,6 +93,7 @@ func (s *Session) authenticate(username, password string) error { if merr != nil { s.backend.Logger.Error("Mailbox authentication error: %v", merr) _ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", merr)) + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(451, "Internal server error") } if mbox != nil { @@ -103,6 +106,7 @@ func (s *Session) authenticate(username, password string) error { } _ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username)) + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(535, "Authentication failed") } diff --git a/internal/smtpserver/mailbox_rules_test.go b/internal/smtpserver/mailbox_rules_test.go index bceacee..3cbf350 100644 --- a/internal/smtpserver/mailbox_rules_test.go +++ b/internal/smtpserver/mailbox_rules_test.go @@ -34,9 +34,13 @@ func TestBlockedSenderRejectedAtRcpt(t *testing.T) { } } -func TestAllowListBypassesSpamRejection(t *testing.T) { +// TestAllowListBypassesSpamQuarantine confirms a zero reject threshold quarantines a +// non-allow-listed sender's mail into Spam (still accepted at SMTP level — spam is +// stored for review, not silently bounced), while an allow-listed sender's mail +// skips scoring entirely and lands in INBOX as normal. +func TestAllowListBypassesSpamQuarantine(t *testing.T) { backend, mailboxID := newTestBackendWithMailbox(t) - // Force every non-allow-listed message to be rejected as spam. + // Force every non-allow-listed message to be quarantined as spam. backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("0") send := func(t *testing.T) error { @@ -63,8 +67,12 @@ func TestAllowListBypassesSpamRejection(t *testing.T) { return w.Close() } - if err := send(t); err == nil { - t.Fatal("expected delivery to fail as spam with a zero reject threshold and no allow-list entry") + if err := send(t); err != nil { + t.Fatalf("expected delivery accepted (quarantined) with a zero reject threshold and no allow-list entry, got: %v", err) + } + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + if err != nil || len(spamMsgs) != 1 { + t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err) } if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com"); err != nil { @@ -73,6 +81,10 @@ func TestAllowListBypassesSpamRejection(t *testing.T) { if err := send(t); err != nil { t.Fatalf("expected delivery to succeed once the sender is allow-listed, got: %v", err) } + inboxMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX") + if err != nil || len(inboxMsgs) != 1 { + t.Fatalf("expected 1 message in INBOX once allow-listed (spam scoring skipped), got %d (err=%v)", len(inboxMsgs), err) + } } func TestFilterRuleDeleteDropsMessage(t *testing.T) { diff --git a/internal/smtpserver/mailbox_spam_test.go b/internal/smtpserver/mailbox_spam_test.go new file mode 100644 index 0000000..341fb24 --- /dev/null +++ b/internal/smtpserver/mailbox_spam_test.go @@ -0,0 +1,85 @@ +package smtpserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/smtp" + "testing" +) + +// fakeRspamd stands in for a real rspamd instance, always returning the fixed +// score/action given — enough to exercise deliverLocally's rspamd branch without a +// live rspamd deployment. +func fakeRspamd(t *testing.T, score float64, action string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"score": score, "action": action}) + })) + t.Cleanup(srv.Close) + return srv +} + +func sendTestMessage(t *testing.T, addr, subject string) error { + t.Helper() + c, err := smtp.Dial(addr) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil { + t.Fatalf("auth: %v", err) + } + if err := c.Mail("test@example.com"); err != nil { + t.Fatalf("MAIL FROM: %v", err) + } + if err := c.Rcpt("inbox@example.com"); err != nil { + t.Fatalf("RCPT: %v", err) + } + w, err := c.Data() + if err != nil { + t.Fatal(err) + } + w.Write([]byte("Subject: " + subject + "\r\n\r\nhi")) + return w.Close() +} + +// TestRspamdExplicitRejectActionStillHardRejects confirms rspamd's own "reject" +// action still hard-rejects at SMTP time (unlike a bare score-threshold hit, which is +// quarantined to Spam instead — see TestRspamdScoreThresholdQuarantinesInsteadOfRejecting). +func TestRspamdExplicitRejectActionStillHardRejects(t *testing.T) { + backend, mailboxID := newTestBackendWithMailbox(t) + rspamd := fakeRspamd(t, 20, "reject") + backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true") + backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL) + addr := startTestServer(t, backend) + + if err := sendTestMessage(t, addr, "hi"); err == nil { + t.Fatal("expected delivery to be hard-rejected when rspamd's action is \"reject\"") + } + inboxMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX") + spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + if len(inboxMsgs) != 0 || len(spamMsgs) != 0 { + t.Fatalf("expected nothing stored anywhere for a hard reject, got INBOX=%d Spam=%d", len(inboxMsgs), len(spamMsgs)) + } +} + +// TestRspamdScoreThresholdQuarantinesInsteadOfRejecting confirms a bare rspamd score +// over the configured threshold (action something other than "reject") is accepted +// and quarantined into Spam, not bounced. +func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(t *testing.T) { + backend, mailboxID := newTestBackendWithMailbox(t) + rspamd := fakeRspamd(t, 20, "add header") + backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true") + backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL) + backend.Cfg.Section("Rspamd").Key("reject_score").SetValue("15") + addr := startTestServer(t, backend) + + if err := sendTestMessage(t, addr, "hi"); err != nil { + t.Fatalf("expected delivery accepted (quarantined), got: %v", err) + } + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + if err != nil || len(spamMsgs) != 1 { + t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err) + } +} diff --git a/internal/smtpserver/session.go b/internal/smtpserver/session.go index e75cc9c..1fc23cd 100644 --- a/internal/smtpserver/session.go +++ b/internal/smtpserver/session.go @@ -11,6 +11,7 @@ import ( "github.com/emersion/go-smtp" "gopkg.in/ini.v1" + "mailgoserver/internal/abuseguard" "mailgoserver/internal/db" "mailgoserver/internal/dkim" "mailgoserver/internal/mailstore" @@ -122,6 +123,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz return true, true, fmt.Sprintf("Sender authorized to send as %s", mailFrom) } _ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "") + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return false, false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom) } @@ -137,6 +139,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz return true, true, fmt.Sprintf("Mailbox authorized to send as alias %s", mailFrom) } _ = s.backend.DB.LogAuthAttempt("mailbox_validation", fmt.Sprintf("%s -> %s", mbox.Email, mailFrom), s.peerIP, false, "") + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return false, false, fmt.Sprintf("Mailbox %s not authorized to send as %s", mbox.Email, mailFrom) } @@ -153,6 +156,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz return true, true, fmt.Sprintf("IP authorized for domain %s", fromDomain) } _ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain)) + abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain) } @@ -370,39 +374,60 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID results := make([]relay.Result, 0, len(rcpts)) for i, rcpt := range rcpts { mbox := s.localMailboxes[strings.ToLower(rcpt)] + folder := "INBOX" + markRead := false // An explicit per-mailbox allow-list entry bypasses spam scoring entirely — // the built-in heuristic and optional rspamd check both run regardless of each // other (additive, not either/or), but neither runs at all once allow-listed. + spamGated := false if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed { - reject := heuristicScore >= rejectScore - if !reject && rspamdEnabled { - if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil { - if action == "reject" || score >= float64(rspamdRejectScore) { - reject = true + quarantine := heuristicScore >= rejectScore + hardReject := false + if rspamdEnabled { + if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil { + // rspamd's own "reject" action is a considered policy decision + // (DNSBL hit, greylisting, etc.) worth still hard-rejecting at + // SMTP time to avoid backscatter; a bare score threshold hit + // (from either scorer) is quarantined instead of rejected, so a + // false positive is recoverable from the Spam folder rather than + // silently bounced with no trace. + if rAction == "reject" { + hardReject = true + } else if score >= float64(rspamdRejectScore) { + quarantine = true } } // rspamd unreachable/erroring must not block mail — errors are swallowed, // the built-in heuristic above is still the baseline gate either way. } - if reject { + if hardReject { results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"}) continue } + if quarantine { + folder = "Spam" + spamGated = true + } } - action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject}) - if err != nil { - results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()}) - continue - } - if action.Drop { - results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"}) - continue - } - folder := "INBOX" - if action.Folder != "" { - folder = action.Folder + // Filter rules organize legitimate mail the recipient already trusts arriving + // in their INBOX — a quarantined message skips them entirely and always lands + // in Spam, rather than a rule accidentally routing spam back into view. + if !spamGated { + action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject}) + if err != nil { + results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()}) + continue + } + if action.Drop { + results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"}) + continue + } + if action.Folder != "" { + folder = action.Folder + } + markRead = action.MarkRead } uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject) @@ -414,12 +439,16 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg}) continue } - if action.MarkRead { + if markRead { if err := s.backend.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil { s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err) } } - results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Delivered to local mailbox"}) + serverResponse := "Delivered to local mailbox" + if spamGated { + serverResponse = "Quarantined to Spam folder" + } + results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse}) } return results } diff --git a/internal/webui/account.go b/internal/webui/account.go index 660a0d4..f4fb68e 100644 --- a/internal/webui/account.go +++ b/internal/webui/account.go @@ -3,6 +3,7 @@ package webui import ( "bytes" "encoding/base64" + "html/template" "image/png" "net/http" "strings" @@ -96,7 +97,11 @@ func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) { qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) } } - a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI}) + // html/template's URL-context escaper only allows http/https/mailto schemes for a + // plain string in a src="..." attribute — anything else, including data: URIs, + // gets silently replaced with "#ZgotmplZ" (confirmed live). template.URL marks + // this value as pre-approved so the actual QR image renders instead of nothing. + a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)}) } // totpSetupConfirm verifies a code against the pending secret and, if correct, flips @@ -114,7 +119,7 @@ func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } - _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator enabled") + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator enabled") setFlash(w, "success", "Authenticator app MFA enabled") http.Redirect(w, r, Prefix+"/account", http.StatusFound) } @@ -124,7 +129,7 @@ func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) { if err := a.DB.DisableAdminTOTP(user.ID); err != nil { setFlash(w, "error", "Something went wrong") } else { - _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator disabled") + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator disabled") setFlash(w, "success", "Authenticator app MFA disabled") } http.Redirect(w, r, Prefix+"/account", http.StatusFound) diff --git a/internal/webui/admins.go b/internal/webui/admins.go index 6487c3d..9f91f8e 100644 --- a/internal/webui/admins.go +++ b/internal/webui/admins.go @@ -232,7 +232,7 @@ func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) { if err := a.DB.ResetAdminMFA(target.ID); err != nil { setFlash(w, "error", "Error resetting MFA") } else { - _ = a.DB.LogAuthAttempt("admin_mfa", target.Username, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) + _ = a.DB.LogAuthAttempt("admin_mfa", target.Username, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) setFlash(w, "success", "MFA reset for "+target.Username) } http.Redirect(w, r, Prefix+"/admins", http.StatusFound) diff --git a/internal/webui/blacklist.go b/internal/webui/blacklist.go new file mode 100644 index 0000000..1af8ded --- /dev/null +++ b/internal/webui/blacklist.go @@ -0,0 +1,106 @@ +package webui + +import ( + "net" + "net/http" +) + +// blacklistPage lists both currently/recently blacklisted IPs (auto or manual) and the +// abuse-detection whitelist — global-admin only, since a blacklist entry isn't +// attributable to one domain the way a mailbox or relay-whitelist row is. +func (a *App) blacklistPage(w http.ResponseWriter, r *http.Request) { + entries, err := a.DB.ListBlacklist() + if err != nil { + setFlash(w, "error", "Error loading blacklist") + } + whitelist, err := a.DB.ListAbuseWhitelist() + if err != nil { + setFlash(w, "error", "Error loading abuse whitelist") + } + a.render(w, r, "blacklist.html", M{"active": "blacklist", "entries": entries, "whitelist": whitelist}) +} + +// addBlacklistEntry is an admin-initiated manual block: fixed duration, no escalation. +func (a *App) addBlacklistEntry(w http.ResponseWriter, r *http.Request) { + ip := r.FormValue("ip_address") + reason := r.FormValue("reason") + hours := atoi(r.FormValue("hours")) + + if net.ParseIP(ip) == nil || hours <= 0 { + setFlash(w, "error", "A valid IP address and a positive duration in hours are required") + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) + return + } + if err := a.DB.AddManualBlacklistEntry(ip, reason, hours); err != nil { + setFlash(w, "error", "Error blacklisting IP") + } else { + setFlash(w, "success", "IP blacklisted") + } + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) +} + +func (a *App) removeBlacklistEntry(w http.ResponseWriter, r *http.Request) { + if err := a.DB.RemoveBlacklistEntry(pathID(r)); err != nil { + setFlash(w, "error", "Error removing blacklist entry") + } else { + setFlash(w, "success", "Blacklist entry removed") + } + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) +} + +// whitelistBlacklistedIP removes ip from the blacklist and adds it to the abuse +// whitelist in one action, so an admin can undo a false-positive auto-block without +// two separate trips. +func (a *App) whitelistBlacklistedIP(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + entries, err := a.DB.ListBlacklist() + if err != nil { + setFlash(w, "error", "Error loading blacklist") + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) + return + } + var ip string + for _, e := range entries { + if e.ID == id { + ip = e.IPAddress + break + } + } + if ip == "" { + http.NotFound(w, r) + return + } + if err := a.DB.AddAbuseWhitelist(ip, "whitelisted from a blacklist entry"); err != nil { + setFlash(w, "error", "Error whitelisting IP") + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) + return + } + _ = a.DB.RemoveBlacklistEntry(id) + setFlash(w, "success", ip+" whitelisted and removed from the blacklist") + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) +} + +func (a *App) addAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) { + ip := r.FormValue("ip_address") + note := r.FormValue("note") + if net.ParseIP(ip) == nil { + setFlash(w, "error", "A valid IP address is required") + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) + return + } + if err := a.DB.AddAbuseWhitelist(ip, note); err != nil { + setFlash(w, "error", "Error adding to abuse whitelist") + } else { + setFlash(w, "success", "IP added to the abuse-detection whitelist") + } + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) +} + +func (a *App) removeAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) { + if err := a.DB.RemoveAbuseWhitelist(pathID(r)); err != nil { + setFlash(w, "error", "Error removing abuse whitelist entry") + } else { + setFlash(w, "success", "Removed from the abuse-detection whitelist") + } + http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound) +} diff --git a/internal/webui/csrf.go b/internal/webui/csrf.go new file mode 100644 index 0000000..33a93d1 --- /dev/null +++ b/internal/webui/csrf.go @@ -0,0 +1,83 @@ +package webui + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" +) + +// csrfSessionCookieNames are tried in order to find whatever session-identifying +// cookie the current request carries — the full admin session, the full mailbox +// session, or either's pending-MFA cookie (covers the MFA step of login, which +// happens before the full session exists but after a pending cookie is set). The +// bare initial login POST, before any of these cookies exist yet, has no source +// token and so is intentionally not CSRF-checked — standard practice, since there's +// no session yet for a forged request to act against. +var csrfSessionCookieNames = []string{ + sessionCookieName, mailboxSessionCookieName, pendingMFACookieName, mailboxPendingMFACookieName, +} + +func csrfSourceToken(r *http.Request) (string, bool) { + for _, name := range csrfSessionCookieNames { + if c, err := r.Cookie(name); err == nil && c.Value != "" { + return c.Value, true + } + } + return "", false +} + +// csrfTokenFor derives this request's expected CSRF token: an HMAC over whatever +// session-identifying cookie is present, keyed by the app secret. Deterministic and +// unstored — recomputed fresh on both render (render.go injects it into every page) +// and validation (CSRFProtect below), so there's no server-side token table to +// manage or expire. +func (a *App) csrfTokenFor(r *http.Request) string { + token, ok := csrfSourceToken(r) + if !ok { + return "" + } + mac := hmac.New(sha256.New, a.appSecret) + mac.Write([]byte(token)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// csrfProtectedMethods are the only ones CSRFProtect checks — GET/HEAD/OPTIONS never +// mutate state in this app (see the M2 fix that removed the one exception that used +// to exist) so they're exempt, matching standard CSRF-defense scope. +var csrfProtectedMethods = map[string]bool{http.MethodPost: true, http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true} + +// CSRFProtect rejects state-changing requests whose csrf_token doesn't match what +// csrfTokenFor computes for the request's own session cookie. Every authenticated +// HTML form gets the token auto-injected as a hidden field, and every same-origin +// fetch() call gets it auto-attached as an X-CSRF-Token header — both via the shared +// csrf_script.html partial parsed into every page (see render.go/loadTemplates) — +// so no individual handler or template needed to change for this to apply +// uniformly across the whole app, admin and webmail alike. +func (a *App) CSRFProtect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !csrfProtectedMethods[r.Method] { + next.ServeHTTP(w, r) + return + } + expected := a.csrfTokenFor(r) + if expected == "" { + // No session cookie at all — an unauthenticated route (e.g. the login POST + // itself). Nothing to protect yet. + next.ServeHTTP(w, r) + return + } + got := r.Header.Get("X-CSRF-Token") + if got == "" { + // Only fall back to parsing the body if the header wasn't already present — + // keeps the common fetch()-with-header path from ever triggering an implicit + // multipart parse here (the handler still parses it normally afterward). + got = r.FormValue("csrf_token") + } + if got == "" || !hmac.Equal([]byte(got), []byte(expected)) { + http.Error(w, "Forbidden: missing or invalid CSRF token", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/webui/csrf_test.go b/internal/webui/csrf_test.go new file mode 100644 index 0000000..efb3594 --- /dev/null +++ b/internal/webui/csrf_test.go @@ -0,0 +1,155 @@ +package webui + +import ( + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +var csrfTokenInPage = regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`) + +// TestCSRFProtectionAppliesAcrossAdminAndWebmail is a live-HTTP test (real +// httptest.NewServer wrapped exactly like main.go composes it — +// SecurityHeaders(app.CSRFProtect(mux)) — not just httptest.NewRecorder against the +// bare mux) confirming: a forged/missing CSRF token on a state-changing POST is +// rejected for BOTH an admin route and a webmail route, a real page-driven +// submission (token scraped from the actual rendered page, exactly as the injected +// csrf_script.html partial would hand it to a real form) succeeds, and every +// response carries the new security headers. +func TestCSRFProtectionAppliesAcrossAdminAndWebmail(t *testing.T) { + app := newTestApp(t) + srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux()))) + defer srv.Close() + + // Security headers present on a plain unauthenticated GET too. + headResp, err := http.Get(srv.URL + Prefix + "/login") + if err != nil { + t.Fatal(err) + } + headResp.Body.Close() + if headResp.Header.Get("X-Frame-Options") != "SAMEORIGIN" { + t.Fatalf("expected X-Frame-Options on every response, got headers: %v", headResp.Header) + } + if headResp.Header.Get("Content-Security-Policy") == "" { + t.Fatal("expected a Content-Security-Policy header") + } + + adminCookie := loginSession(t, app) + domains, _ := app.DB.ListDomains() + mailboxID := createTestMailboxWithPassword(t, app, "csrf-mailbox@example.com", domains[0].ID, "csrf-password-1!") + mailboxCookie := webmailLoginSession(t, app, mailboxID) + + jarClient := func(cookie *http.Cookie) *http.Client { + jar, _ := cookiejar.New(nil) + u, _ := url.Parse(srv.URL) + jar.SetCookies(u, []*http.Cookie{cookie}) + return &http.Client{Jar: jar} + } + + // Regression check: the compose popup (webmail_compose_widget.html) loads + // /webmail/mail/compose in a same-origin +
+ + +{{end}} diff --git a/internal/webui/templates/webmail_folder.html b/internal/webui/templates/webmail_folder.html new file mode 100644 index 0000000..f26df66 --- /dev/null +++ b/internal/webui/templates/webmail_folder.html @@ -0,0 +1,282 @@ +{{define "webmail_folder.html"}} + + + + + + {{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail + + + + + + {{template "csrf_script" .}} + + +
+ {{range .flashes}} + + {{end}} +
+ +
+
+
+
+
+
+
+ + +
+
+
+ {{$active := .active_folder}} + {{$unread := .unread_counts}} + {{range .folders}} +
+ + {{.}} + {{$n := index $unread .}} + {{if $n}}{{$n}}{{end}} + + {{if not (isStandardFolder .)}} +
+ +
+ {{end}} +
+ {{end}} +
+
+
+ + +
+
+
+
+ +
+
+
+
+ {{if .search_query}}Search results for “{{.search_query}}” + {{else}}{{.active_folder}}{{end}} +
+ {{.total}} message{{if ne .total 1}}s{{end}} +
+
+ {{if .messages}} +
+ + + + {{if not .search_query}}{{else}}{{end}} + + {{if .search_query}}{{end}} + + + + + + {{$folders := .folders}} + {{$showFolderCol := .search_query}} + {{range .messages}} + {{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}} + {{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}} + + + + {{if $showFolderCol}}{{end}} + + + + {{end}} + +
{{if eq .active_folder "Sent"}}To{{else}}From{{end}}From / ToSubjectFolderDate
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}} + {{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}} + {{if gt .GroupExtra 0}}+{{.GroupExtra}} more{{end}} + {{.Folder}}{{strftime "%Y-%m-%d %H:%M" .InternalDate}} +
+
+ +
+
+ +
+
+
+
+ {{if or .has_prev .has_next}} +
+ {{if .has_prev}}« Newer{{else}}{{end}} + {{if .has_next}}Older »{{end}} +
+ {{end}} + {{else}} +
+ +
No messages in {{.active_folder}}
+
+ {{end}} +
+
+
+
+
+ + + + {{template "compose_widget" .}} + {{template "webmail_shortcuts" .}} + + + + + +{{end}} diff --git a/internal/webui/templates/webmail_login.html b/internal/webui/templates/webmail_login.html index ce02b1e..115c1d5 100644 --- a/internal/webui/templates/webmail_login.html +++ b/internal/webui/templates/webmail_login.html @@ -5,8 +5,8 @@ Sign in - Webmail - - + + + {{template "csrf_script" .}}
diff --git a/internal/webui/templates/webmail_login_mfa.html b/internal/webui/templates/webmail_login_mfa.html index 64a8bed..3c6b660 100644 --- a/internal/webui/templates/webmail_login_mfa.html +++ b/internal/webui/templates/webmail_login_mfa.html @@ -5,8 +5,8 @@ Verify it's you - Webmail - - + + + {{template "csrf_script" .}}