mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
dkim: Implement internal dkim signing and verification
This patch implements internal DKIM signing and verification.
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"blitiri.com.ar/go/chasquid/internal/aliases"
|
||||
"blitiri.com.ar/go/chasquid/internal/auth"
|
||||
"blitiri.com.ar/go/chasquid/internal/dkim"
|
||||
"blitiri.com.ar/go/chasquid/internal/domaininfo"
|
||||
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||
"blitiri.com.ar/go/chasquid/internal/expvarom"
|
||||
@@ -51,6 +52,20 @@ var (
|
||||
"result", "count of hook invocations, by result")
|
||||
wrongProtoCount = expvarom.NewMap("chasquid/smtpIn/wrongProtoCount",
|
||||
"command", "count of commands for other protocols")
|
||||
|
||||
dkimSigned = expvarom.NewInt("chasquid/smtpIn/dkimSigned",
|
||||
"count of successful DKIM signs")
|
||||
dkimSignErrors = expvarom.NewInt("chasquid/smtpIn/dkimSignErrors",
|
||||
"count of DKIM sign errors")
|
||||
|
||||
dkimVerifyFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyFound",
|
||||
"count of messages with at least one DKIM signature")
|
||||
dkimVerifyNotFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyNotFound",
|
||||
"count of messages with no DKIM signatures")
|
||||
dkimVerifyValid = expvarom.NewInt("chasquid/smtpIn/dkimVerifyValid",
|
||||
"count of messages with at least one valid DKIM signature")
|
||||
dkimVerifyErrors = expvarom.NewInt("chasquid/smtpIn/dkimVerifyErrors",
|
||||
"count of DKIM verification errors")
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -129,6 +144,9 @@ type Conn struct {
|
||||
spfResult spf.Result
|
||||
spfError error
|
||||
|
||||
// DKIM verification results.
|
||||
dkimVerifyResult *dkim.VerifyResult
|
||||
|
||||
// Are we using TLS?
|
||||
onTLS bool
|
||||
|
||||
@@ -142,6 +160,9 @@ type Conn struct {
|
||||
aliasesR *aliases.Resolver
|
||||
dinfo *domaininfo.DB
|
||||
|
||||
// Map of domain -> DKIM signers. Taken from the server at creation time.
|
||||
dkimSigners map[string][]*dkim.Signer
|
||||
|
||||
// Have we successfully completed AUTH?
|
||||
completedAuth bool
|
||||
|
||||
@@ -666,6 +687,18 @@ func (c *Conn) DATA(params string) (code int, msg string) {
|
||||
return 554, err.Error()
|
||||
}
|
||||
|
||||
if c.completedAuth {
|
||||
err = c.dkimSign()
|
||||
if err != nil {
|
||||
// If we failed to sign, then reject to prevent sending unsigned
|
||||
// messages. Treat the failure as temporary.
|
||||
c.tr.Errorf("DKIM failed: %v", err)
|
||||
return 451, "4.3.0 DKIM signing failed"
|
||||
}
|
||||
} else {
|
||||
c.dkimVerify()
|
||||
}
|
||||
|
||||
c.addReceivedHeader()
|
||||
|
||||
hookOut, permanent, err := c.runPostDataHook(c.data)
|
||||
@@ -704,7 +737,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
|
||||
}
|
||||
|
||||
func (c *Conn) addReceivedHeader() {
|
||||
var v string
|
||||
var received string
|
||||
|
||||
// Format is semi-structured, defined by
|
||||
// https://tools.ietf.org/html/rfc5321#section-4.4
|
||||
@@ -712,16 +745,16 @@ func (c *Conn) addReceivedHeader() {
|
||||
if c.completedAuth {
|
||||
// For authenticated users, only show the EHLO domain they gave;
|
||||
// explicitly hide their network address.
|
||||
v += fmt.Sprintf("from %s\n", c.ehloDomain)
|
||||
received += fmt.Sprintf("from %s\n", c.ehloDomain)
|
||||
} else {
|
||||
// For non-authenticated users we show the real address as canonical,
|
||||
// and then the given EHLO domain for convenience and
|
||||
// troubleshooting.
|
||||
v += fmt.Sprintf("from [%s] (%s)\n",
|
||||
received += fmt.Sprintf("from [%s] (%s)\n",
|
||||
addrLiteral(c.remoteAddr), c.ehloDomain)
|
||||
}
|
||||
|
||||
v += fmt.Sprintf("by %s (chasquid) ", c.hostname)
|
||||
received += fmt.Sprintf("by %s (chasquid) ", c.hostname)
|
||||
|
||||
// https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#mail-parameters-7
|
||||
with := "SMTP"
|
||||
@@ -734,35 +767,60 @@ func (c *Conn) addReceivedHeader() {
|
||||
if c.completedAuth {
|
||||
with += "A"
|
||||
}
|
||||
v += fmt.Sprintf("with %s\n", with)
|
||||
received += fmt.Sprintf("with %s\n", with)
|
||||
|
||||
if c.tlsConnState != nil {
|
||||
// https://tools.ietf.org/html/rfc8314#section-4.3
|
||||
v += fmt.Sprintf("tls %s\n",
|
||||
received += fmt.Sprintf("tls %s\n",
|
||||
tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite))
|
||||
}
|
||||
|
||||
v += fmt.Sprintf("(over %s, ", c.mode)
|
||||
received += fmt.Sprintf("(over %s, ", c.mode)
|
||||
if c.tlsConnState != nil {
|
||||
v += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version))
|
||||
received += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version))
|
||||
} else {
|
||||
v += "plain text!, "
|
||||
received += "plain text!, "
|
||||
}
|
||||
|
||||
// Note we must NOT include c.rcptTo, that would leak BCCs.
|
||||
v += fmt.Sprintf("envelope from %q)\n", c.mailFrom)
|
||||
received += fmt.Sprintf("envelope from %q)\n", c.mailFrom)
|
||||
|
||||
// This should be the last part in the Received header, by RFC.
|
||||
// The ";" is a mandatory separator. The date format is not standard but
|
||||
// this one seems to be widely used.
|
||||
// https://tools.ietf.org/html/rfc5322#section-3.6.7
|
||||
v += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z))
|
||||
c.data = envelope.AddHeader(c.data, "Received", v)
|
||||
received += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z))
|
||||
c.data = envelope.AddHeader(c.data, "Received", received)
|
||||
|
||||
// Add Authentication-Results header too, but only if there's anything to
|
||||
// report. We add it above the Received header, so it can easily be
|
||||
// associated and traced to it, even though it is not a hard requirement.
|
||||
// Note we include results even if they're "none" or "neutral", as that
|
||||
// allows MUAs to know that the message was checked.
|
||||
arHdr := c.hostname + "\r\n"
|
||||
includeAR := false
|
||||
|
||||
if c.spfResult != "" {
|
||||
// https://tools.ietf.org/html/rfc7208#section-9.1
|
||||
v = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError)
|
||||
c.data = envelope.AddHeader(c.data, "Received-SPF", v)
|
||||
received = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError)
|
||||
c.data = envelope.AddHeader(c.data, "Received-SPF", received)
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.2
|
||||
arHdr += fmt.Sprintf(";spf=%s (%v)\r\n", c.spfResult, c.spfError)
|
||||
includeAR = true
|
||||
}
|
||||
|
||||
if c.dkimVerifyResult != nil {
|
||||
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
|
||||
arHdr += c.dkimVerifyResult.AuthenticationResults() + "\r\n"
|
||||
includeAR = true
|
||||
}
|
||||
|
||||
if includeAR {
|
||||
// Only include the Authentication-Results header if we have something
|
||||
// to report.
|
||||
c.data = envelope.AddHeader(c.data, "Authentication-Results",
|
||||
strings.TrimSpace(arHdr))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,6 +1015,79 @@ func boolToStr(b bool) string {
|
||||
return "0"
|
||||
}
|
||||
|
||||
func (c *Conn) dkimSign() error {
|
||||
// We only sign if the user authenticated. However, the authenticated user
|
||||
// and the MAIL FROM address may be different; even the domain may be
|
||||
// different.
|
||||
// We explicitly let this happen and trust authenticated users.
|
||||
// So for DKIM signing purposes, we use the MAIL FROM domain: this
|
||||
// prevents leaking the authenticated user's domain, and is more in line
|
||||
// with expectations around signatures.
|
||||
domain := envelope.DomainOf(c.mailFrom)
|
||||
signers := c.dkimSigners[domain]
|
||||
if len(signers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tr := c.tr.NewChild("DKIM.Sign", domain)
|
||||
defer tr.Finish()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
|
||||
|
||||
for _, signer := range signers {
|
||||
sig, err := signer.Sign(ctx, normalize.StringToCRLF(string(c.data)))
|
||||
if err != nil {
|
||||
dkimSignErrors.Add(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// The signature is returned with \r\n; however, our internal
|
||||
// representation uses \n, so normalize it.
|
||||
sig = strings.ReplaceAll(sig, "\r\n", "\n")
|
||||
c.data = envelope.AddHeader(c.data, "DKIM-Signature", sig)
|
||||
}
|
||||
dkimSigned.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) dkimVerify() {
|
||||
tr := c.tr.NewChild("DKIM.Verify", c.mailFrom)
|
||||
defer tr.Finish()
|
||||
|
||||
var err error
|
||||
ctx := context.Background()
|
||||
ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
|
||||
|
||||
c.dkimVerifyResult, err = dkim.VerifyMessage(
|
||||
ctx, string(normalize.ToCRLF(c.data)))
|
||||
if err != nil {
|
||||
// The only error we expect is because of a malformed mail, which is
|
||||
// checked before this is invoked.
|
||||
tr.Errorf("Error verifying DKIM: %v", err)
|
||||
dkimVerifyErrors.Add(1)
|
||||
}
|
||||
|
||||
if c.dkimVerifyResult != nil {
|
||||
if c.dkimVerifyResult.Found > 0 {
|
||||
dkimVerifyFound.Add(1)
|
||||
} else {
|
||||
dkimVerifyNotFound.Add(1)
|
||||
}
|
||||
|
||||
if c.dkimVerifyResult.Valid > 0 {
|
||||
dkimVerifyValid.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Note we don't fail emails because they failed to verify, in line
|
||||
// with RFC recommendations.
|
||||
// DMARC policies may cause it to fail at some point, but that is not
|
||||
// implemented yet, and would happen separately.
|
||||
// The results will get included in the Authentication-Results header, see
|
||||
// addReceivedHeader for more details.
|
||||
}
|
||||
|
||||
// STARTTLS SMTP command handler.
|
||||
func (c *Conn) STARTTLS(params string) (code int, msg string) {
|
||||
if c.onTLS {
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
package smtpsrv
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"blitiri.com.ar/go/chasquid/internal/aliases"
|
||||
"blitiri.com.ar/go/chasquid/internal/auth"
|
||||
"blitiri.com.ar/go/chasquid/internal/courier"
|
||||
"blitiri.com.ar/go/chasquid/internal/dkim"
|
||||
"blitiri.com.ar/go/chasquid/internal/domaininfo"
|
||||
"blitiri.com.ar/go/chasquid/internal/localrpc"
|
||||
"blitiri.com.ar/go/chasquid/internal/maillog"
|
||||
@@ -65,6 +73,9 @@ type Server struct {
|
||||
// Domain info database.
|
||||
dinfo *domaininfo.DB
|
||||
|
||||
// Map of domain -> DKIM signers.
|
||||
dkimSigners map[string][]*dkim.Signer
|
||||
|
||||
// Time before we give up on a connection, even if it's sending data.
|
||||
connTimeout time.Duration
|
||||
|
||||
@@ -91,6 +102,7 @@ func NewServer() *Server {
|
||||
localDomains: &set.String{},
|
||||
authr: authr,
|
||||
aliasesR: aliasesR,
|
||||
dkimSigners: map[string][]*dkim.Signer{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +142,48 @@ func (s *Server) AddAliasesFile(domain, f string) error {
|
||||
return s.aliasesR.AddAliasesFile(domain, f)
|
||||
}
|
||||
|
||||
var (
|
||||
errDecodingPEMBlock = fmt.Errorf("error decoding PEM block")
|
||||
errUnsupportedBlockType = fmt.Errorf("unsupported block type")
|
||||
errUnsupportedKeyType = fmt.Errorf("unsupported key type")
|
||||
)
|
||||
|
||||
// AddDKIMSigner for the given domain and selector.
|
||||
func (s *Server) AddDKIMSigner(domain, selector, keyPath string) error {
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(key)
|
||||
if block == nil {
|
||||
return errDecodingPEMBlock
|
||||
}
|
||||
|
||||
if strings.ToUpper(block.Type) != "PRIVATE KEY" {
|
||||
return fmt.Errorf("%w: %s", errUnsupportedBlockType, block.Type)
|
||||
}
|
||||
|
||||
signer, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch k := signer.(type) {
|
||||
case *rsa.PrivateKey, ed25519.PrivateKey:
|
||||
// These are supported, nothing to do.
|
||||
default:
|
||||
return fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
|
||||
}
|
||||
|
||||
s.dkimSigners[domain] = append(s.dkimSigners[domain], &dkim.Signer{
|
||||
Domain: domain,
|
||||
Selector: selector,
|
||||
Signer: signer.(crypto.Signer),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAuthFallback sets the authentication backend to use as fallback.
|
||||
func (s *Server) SetAuthFallback(be auth.Backend) {
|
||||
s.authr.Fallback = be
|
||||
@@ -287,6 +341,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
|
||||
aliasesR: s.aliasesR,
|
||||
localDomains: s.localDomains,
|
||||
dinfo: s.dinfo,
|
||||
dkimSigners: s.dkimSigners,
|
||||
deadline: time.Now().Add(s.connTimeout),
|
||||
commandTimeout: s.commandTimeout,
|
||||
queue: s.queue,
|
||||
|
||||
@@ -2,11 +2,13 @@ package smtpsrv
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -481,6 +483,69 @@ func TestStartTLSOnTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDKIMSigner(t *testing.T) {
|
||||
s := NewServer()
|
||||
err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist")
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("AddDKIMSigner: expected not exist, got %v", err)
|
||||
}
|
||||
|
||||
tmpDir := testlib.MustTempDir(t)
|
||||
defer testlib.RemoveIfOk(t, tmpDir)
|
||||
|
||||
// Invalid PEM file.
|
||||
kf1 := tmpDir + "/key1-bad_pem.pem"
|
||||
testlib.Rewrite(t, kf1, "not a valid PEM file")
|
||||
err = s.AddDKIMSigner("example.com", "selector", kf1)
|
||||
if !errors.Is(err, errDecodingPEMBlock) {
|
||||
t.Errorf("AddDKIMSigner: expected %v, got %v",
|
||||
errDecodingPEMBlock, err)
|
||||
}
|
||||
|
||||
// Unsupported block type.
|
||||
kf2 := tmpDir + "/key2.pem"
|
||||
testlib.Rewrite(t, kf2,
|
||||
"-----BEGIN TEST KEY-----\n-----END TEST KEY-----")
|
||||
err = s.AddDKIMSigner("example.com", "selector", kf2)
|
||||
if !errors.Is(err, errUnsupportedBlockType) {
|
||||
t.Errorf("AddDKIMSigner: expected %v, got %v",
|
||||
errUnsupportedBlockType, err)
|
||||
}
|
||||
|
||||
// x509 error: this is an ed448 key, which is not supported.
|
||||
kf3 := tmpDir + "/key3.pem"
|
||||
testlib.Rewrite(t, kf3, `-----BEGIN PRIVATE KEY-----
|
||||
MEcCAQAwBQYDK2VxBDsEOSBHT9DNG6/FNBnRGrLay+jIrK8WrViiVMz9AoXqYSb6
|
||||
ghwTZSd3E0X8oIFTgs9ch3pxJM1KDrs4NA==
|
||||
-----END PRIVATE KEY-----`)
|
||||
err = s.AddDKIMSigner("example.com", "selector", kf3)
|
||||
if !strings.Contains(err.Error(),
|
||||
"x509: PKCS#8 wrapping contained private key with unknown algorithm") {
|
||||
t.Errorf("AddDKIMSigner: expected x509 error, got %q", err.Error())
|
||||
}
|
||||
|
||||
// Unsupported key type: X25519.
|
||||
kf4 := tmpDir + "/key4.pem"
|
||||
testlib.Rewrite(t, kf4, `-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VuBCIEIKBUDwEDc5cCv/yEvnA93yk0gXyiTZe7Qip8QU3rJuZC
|
||||
-----END PRIVATE KEY-----`)
|
||||
err = s.AddDKIMSigner("example.com", "selector", kf4)
|
||||
if !errors.Is(err, errUnsupportedKeyType) {
|
||||
t.Errorf("AddDKIMSigner: expected %v, got %v",
|
||||
errUnsupportedKeyType, err)
|
||||
}
|
||||
|
||||
// Successful.
|
||||
kf5 := tmpDir + "/key5.pem"
|
||||
testlib.Rewrite(t, kf5, `-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
|
||||
-----END PRIVATE KEY-----`)
|
||||
err = s.AddDKIMSigner("example.com", "selector", kf5)
|
||||
if err != nil {
|
||||
t.Errorf("AddDKIMSigner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// === Benchmarks ===
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user