This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

849
dkim/dkim.go Normal file
View File

@ -0,0 +1,849 @@
// Package dkim (DomainKeys Identified Mail signatures, RFC 6376) signs and
// verifies DKIM signatures.
//
// Signatures are added to email messages in DKIM-Signature headers. By signing a
// message, a domain takes responsibility for the message. A message can have
// signatures for multiple domains, and the domain does not necessarily have to
// match a domain in a From header. Receiving mail servers can build a spaminess
// reputation based on domains that signed the message, along with other
// mechanisms.
package dkim
import (
"bufio"
"bytes"
"context"
"crypto"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"hash"
"io"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/publicsuffix"
"github.com/mjl-/mox/smtp"
)
var xlog = mlog.New("dkim")
var (
metricDKIMSign = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_dkim_sign_total",
Help: "DKIM messages signings.",
},
[]string{
"key",
},
)
metricDKIMVerify = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mox_dkim_verify_duration_seconds",
Help: "DKIM verify, including lookup, duration and result.",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
},
[]string{
"algorithm",
"status",
},
)
)
var timeNow = time.Now // Replaced during tests.
// Status is the result of verifying a DKIM-Signature as described by RFC 8601,
// "Message Header Field for Indicating Message Authentication Status".
type Status string
// ../rfc/8601:959 ../rfc/6376:1770 ../rfc/6376:2459
const (
StatusNone Status = "none" // Message was not signed.
StatusPass Status = "pass" // Message was signed and signature was verified.
StatusFail Status = "fail" // Message was signed, but signature was invalid.
StatusPolicy Status = "policy" // Message was signed, but signature is not accepted by policy.
StatusNeutral Status = "neutral" // Message was signed, but the signature contains an error or could not be processed. This status is also used for errors not covered by other statuses.
StatusTemperror Status = "temperror" // Message could not be verified. E.g. because of DNS resolve error. A later attempt may succeed. A missing DNS record is treated as temporary error, a new key may not have propagated through DNS shortly after it was taken into use.
StatusPermerror Status = "permerror" // Message cannot be verified. E.g. when a required header field is absent or for invalid (combination of) parameters. Typically set if a DNS record does not allow the signature, e.g. due to algorithm mismatch or expiry.
)
// Lookup errors.
var (
ErrNoRecord = errors.New("dkim: no dkim dns record for selector and domain")
ErrMultipleRecords = errors.New("dkim: multiple dkim dns record for selector and domain")
ErrDNS = errors.New("dkim: lookup of dkim dns record")
ErrSyntax = errors.New("dkim: syntax error in dkim dns record")
)
// Signature verification errors.
var (
ErrSigAlgMismatch = errors.New("dkim: signature algorithm mismatch with dns record")
ErrHashAlgNotAllowed = errors.New("dkim: hash algorithm not allowed by dns record")
ErrKeyNotForEmail = errors.New("dkim: dns record not allowed for use with email")
ErrDomainIdentityMismatch = errors.New("dkim: dns record disallows mismatch of domain (d=) and identity (i=)")
ErrSigExpired = errors.New("dkim: signature has expired")
ErrHashAlgorithmUnknown = errors.New("dkim: unknown hash algorithm")
ErrBodyhashMismatch = errors.New("dkim: body hash does not match")
ErrSigVerify = errors.New("dkim: signature verification failed")
ErrSigAlgorithmUnknown = errors.New("dkim: unknown signature algorithm")
ErrCanonicalizationUnknown = errors.New("dkim: unknown canonicalization")
ErrHeaderMalformed = errors.New("dkim: mail message header is malformed")
ErrFrom = errors.New("dkim: bad from headers")
ErrQueryMethod = errors.New("dkim: no recognized query method")
ErrKeyRevoked = errors.New("dkim: key has been revoked")
ErrTLD = errors.New("dkim: signed domain is top-level domain, above organizational domain")
ErrPolicy = errors.New("dkim: signature rejected by policy")
ErrWeakKey = errors.New("dkim: key is too weak, need at least 1024 bits for rsa")
)
// Result is the conclusion of verifying one DKIM-Signature header. An email can
// have multiple signatures, each with different parameters.
//
// To decide what to do with a message, both the signature parameters and the DNS
// TXT record have to be consulted.
type Result struct {
Status Status
Sig *Sig // Parsed form of DKIM-Signature header. Can be nil for invalid DKIM-Signature header.
Record *Record // Parsed form of DKIM DNS record for selector and domain in Sig. Optional.
Err error // If Status is not StatusPass, this error holds the details and can be checked using errors.Is.
}
// todo: use some io.Writer to hash the body and the header.
// Sign returns line(s) with DKIM-Signature headers, generated according to the configuration.
func Sign(ctx context.Context, localpart smtp.Localpart, domain dns.Domain, c config.DKIM, smtputf8 bool, msg io.ReaderAt) (headers string, rerr error) {
log := xlog.WithContext(ctx)
start := timeNow()
defer func() {
log.Debugx("dkim sign result", rerr, mlog.Field("localpart", localpart), mlog.Field("domain", domain), mlog.Field("smtputf8", smtputf8), mlog.Field("duration", time.Since(start)))
}()
hdrs, bodyOffset, err := parseHeaders(bufio.NewReader(&moxio.AtReader{R: msg}))
if err != nil {
return "", fmt.Errorf("%w: %s", ErrHeaderMalformed, err)
}
nfrom := 0
for _, h := range hdrs {
if h.lkey == "from" {
nfrom++
}
}
if nfrom != 1 {
return "", fmt.Errorf("%w: message has %d from headers, need exactly 1", ErrFrom, nfrom)
}
type hashKey struct {
simple bool // Canonicalization.
hash string // lower-case hash.
}
var bodyHashes = map[hashKey][]byte{}
for _, sign := range c.Sign {
sel := c.Selectors[sign]
sig := newSigWithDefaults()
sig.Version = 1
switch sel.Key.(type) {
case *rsa.PrivateKey:
sig.AlgorithmSign = "rsa"
metricDKIMSign.WithLabelValues("rsa").Inc()
case ed25519.PrivateKey:
sig.AlgorithmSign = "ed25519"
metricDKIMSign.WithLabelValues("ed25519").Inc()
default:
return "", fmt.Errorf("internal error, unknown pivate key %T", sel.Key)
}
sig.AlgorithmHash = sel.HashEffective
sig.Domain = domain
sig.Selector = sel.Domain
sig.Identity = &Identity{&localpart, domain}
sig.SignedHeaders = append([]string{}, sel.HeadersEffective...)
if !sel.DontSealHeaders {
// ../rfc/6376:2156
// Each time a header name is added to the signature, the next unused value is
// signed (in reverse order as they occur in the message). So we can add each
// header name as often as it occurs. But now we'll add the header names one
// additional time, preventing someone from adding one more header later on.
counts := map[string]int{}
for _, h := range hdrs {
counts[h.lkey]++
}
for _, h := range sel.HeadersEffective {
for j := counts[strings.ToLower(h)]; j > 0; j-- {
sig.SignedHeaders = append(sig.SignedHeaders, h)
}
}
}
sig.SignTime = timeNow().Unix()
if sel.ExpirationSeconds > 0 {
sig.ExpireTime = sig.SignTime + int64(sel.ExpirationSeconds)
}
sig.Canonicalization = "simple"
if sel.Canonicalization.HeaderRelaxed {
sig.Canonicalization = "relaxed"
}
sig.Canonicalization += "/"
if sel.Canonicalization.BodyRelaxed {
sig.Canonicalization += "relaxed"
} else {
sig.Canonicalization += "simple"
}
h, hok := algHash(sig.AlgorithmHash)
if !hok {
return "", fmt.Errorf("unrecognized hash algorithm %q", sig.AlgorithmHash)
}
// We must now first calculate the hash over the body. Then include that hash in a
// new DKIM-Signature header. Then hash that and the signed headers into a data
// hash. Then that hash is finally signed and the signature included in the new
// DKIM-Signature header.
// ../rfc/6376:1700
hk := hashKey{!sel.Canonicalization.BodyRelaxed, strings.ToLower(sig.AlgorithmHash)}
if bh, ok := bodyHashes[hk]; ok {
sig.BodyHash = bh
} else {
br := bufio.NewReader(&moxio.AtReader{R: msg, Offset: int64(bodyOffset)})
bh, err = bodyHash(h.New(), !sel.Canonicalization.BodyRelaxed, br)
if err != nil {
return "", err
}
sig.BodyHash = bh
bodyHashes[hk] = bh
}
sigh, err := sig.Header()
if err != nil {
return "", err
}
verifySig := []byte(strings.TrimSuffix(sigh, "\r\n"))
dh, err := dataHash(h.New(), !sel.Canonicalization.HeaderRelaxed, sig, hdrs, verifySig)
if err != nil {
return "", err
}
switch key := sel.Key.(type) {
case *rsa.PrivateKey:
sig.Signature, err = key.Sign(cryptorand.Reader, dh, h)
if err != nil {
return "", fmt.Errorf("signing data: %v", err)
}
case ed25519.PrivateKey:
// crypto.Hash(0) indicates data isn't prehashed (ed25519ph). We are using
// PureEdDSA to sign the sha256 hash. ../rfc/8463:123 ../rfc/8032:427
sig.Signature, err = key.Sign(cryptorand.Reader, dh, crypto.Hash(0))
if err != nil {
return "", fmt.Errorf("signing data: %v", err)
}
default:
return "", fmt.Errorf("unsupported private key type: %s", err)
}
sigh, err = sig.Header()
if err != nil {
return "", err
}
headers += sigh
}
return headers, nil
}
// Lookup looks up the DKIM TXT record and parses it.
//
// A requested record is <selector>._domainkey.<domain>. Exactly one valid DKIM
// record should be present.
func Lookup(ctx context.Context, resolver dns.Resolver, selector, domain dns.Domain) (rstatus Status, rrecord *Record, rtxt string, rerr error) {
log := xlog.WithContext(ctx)
start := timeNow()
defer func() {
log.Debugx("dkim lookup result", rerr, mlog.Field("selector", selector), mlog.Field("domain", domain), mlog.Field("status", rstatus), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
}()
name := selector.ASCII + "._domainkey." + domain.ASCII + "."
records, err := dns.WithPackage(resolver, "dkim").LookupTXT(ctx, name)
if dns.IsNotFound(err) {
// ../rfc/6376:2608
// We must return StatusPermerror. We may want to return StatusTemperror because in
// practice someone will start using a new key before DNS changes have propagated.
return StatusPermerror, nil, "", fmt.Errorf("%w: dns name %q", ErrNoRecord, name)
} else if err != nil {
return StatusTemperror, nil, "", fmt.Errorf("%w: dns name %q: %s", ErrDNS, name, err)
}
// ../rfc/6376:2612
var status = StatusTemperror
var record *Record
var txt string
err = nil
for _, s := range records {
// We interpret ../rfc/6376:2621 to mean that a record that claims to be v=DKIM1,
// but isn't actually valid, results in a StatusPermFail. But a record that isn't
// claiming to be DKIM1 is ignored.
var r *Record
var isdkim bool
r, isdkim, err = ParseRecord(s)
if err != nil && isdkim {
return StatusPermerror, nil, txt, fmt.Errorf("%w: %s", ErrSyntax, err)
} else if err != nil {
// Hopefully the remote MTA admin discovers the configuration error and fix it for
// an upcoming delivery attempt, in case we rejected with temporary status.
status = StatusTemperror
err = fmt.Errorf("%w: not a dkim record: %s", ErrSyntax, err)
continue
}
// If there are multiple valid records, return a temporary error. Perhaps the error is fixed soon.
// ../rfc/6376:1609
// ../rfc/6376:2584
if record != nil {
return StatusTemperror, nil, "", fmt.Errorf("%w: dns name %q", ErrMultipleRecords, name)
}
record = r
txt = s
err = nil
}
if record == nil {
return status, nil, "", err
}
return StatusNeutral, record, txt, nil
}
// Verify parses the DKIM-Signature headers in a message and verifies each of them.
//
// If the headers of the message cannot be found, an error is returned.
// Otherwise, each DKIM-Signature header is reflected in the returned results.
//
// NOTE: Verify does not check if the domain (d=) that signed the message is
// the domain of the sender. The caller, e.g. through DMARC, should do this.
//
// If ignoreTestMode is true and the DKIM record is in test mode (t=y), a
// verification failure is treated as actual failure. With ignoreTestMode
// false, such verification failures are treated as if there is no signature by
// returning StatusNone.
func Verify(ctx context.Context, resolver dns.Resolver, smtputf8 bool, policy func(*Sig) error, r io.ReaderAt, ignoreTestMode bool) (results []Result, rerr error) {
log := xlog.WithContext(ctx)
start := timeNow()
defer func() {
duration := float64(time.Since(start)) / float64(time.Second)
for _, r := range results {
var alg string
if r.Sig != nil {
alg = r.Sig.Algorithm()
}
status := string(r.Status)
metricDKIMVerify.WithLabelValues(alg, status).Observe(duration)
}
if len(results) == 0 {
log.Debugx("dkim verify result", rerr, mlog.Field("smtputf8", smtputf8), mlog.Field("duration", time.Since(start)))
}
for _, result := range results {
log.Debugx("dkim verify result", result.Err, mlog.Field("smtputf8", smtputf8), mlog.Field("status", result.Status), mlog.Field("sig", result.Sig), mlog.Field("record", result.Record), mlog.Field("duration", time.Since(start)))
}
}()
hdrs, bodyOffset, err := parseHeaders(bufio.NewReader(&moxio.AtReader{R: r}))
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrHeaderMalformed, err)
}
// todo: reuse body hashes and possibly verify signatures in parallel. and start the dns lookup immediately. ../rfc/6376:2697
for _, h := range hdrs {
if h.lkey != "dkim-signature" {
continue
}
sig, verifySig, err := parseSignature(h.raw, smtputf8)
if err != nil {
// ../rfc/6376:2503
err := fmt.Errorf("parsing DKIM-Signature header: %w", err)
results = append(results, Result{StatusPermerror, nil, nil, err})
continue
}
h, canonHeaderSimple, canonDataSimple, err := checkSignatureParams(ctx, sig)
if err != nil {
results = append(results, Result{StatusPermerror, nil, nil, err})
continue
}
// ../rfc/6376:2560
if err := policy(sig); err != nil {
err := fmt.Errorf("%w: %s", ErrPolicy, err)
results = append(results, Result{StatusPolicy, nil, nil, err})
continue
}
br := bufio.NewReader(&moxio.AtReader{R: r, Offset: int64(bodyOffset)})
status, txt, err := verifySignature(ctx, resolver, sig, h, canonHeaderSimple, canonDataSimple, hdrs, verifySig, br, ignoreTestMode)
results = append(results, Result{status, sig, txt, err})
}
return results, nil
}
// check if signature is acceptable.
// Only looks at the signature parameters, not at the DNS record.
func checkSignatureParams(ctx context.Context, sig *Sig) (hash crypto.Hash, canonHeaderSimple, canonBodySimple bool, rerr error) {
// "From" header is required, ../rfc/6376:2122 ../rfc/6376:2546
var from bool
for _, h := range sig.SignedHeaders {
if strings.EqualFold(h, "from") {
from = true
break
}
}
if !from {
return 0, false, false, fmt.Errorf(`%w: required "from" header not signed`, ErrFrom)
}
// ../rfc/6376:2550
if sig.ExpireTime >= 0 && sig.ExpireTime < timeNow().Unix() {
return 0, false, false, fmt.Errorf("%w: expiration time %q", ErrSigExpired, time.Unix(sig.ExpireTime, 0).Format(time.RFC3339))
}
// ../rfc/6376:2554
// ../rfc/6376:3284
// Refuse signatures that reach beyond declared scope. We use the existing
// publicsuffix.Lookup to lookup a fake subdomain of the signing domain. If this
// supposed subdomain is actually an organizational domain, the signing domain
// shouldn't be signing for its organizational domain.
subdom := sig.Domain
subdom.ASCII = "x." + subdom.ASCII
if subdom.Unicode != "" {
subdom.Unicode = "x." + subdom.Unicode
}
if orgDom := publicsuffix.Lookup(ctx, subdom); subdom.ASCII == orgDom.ASCII {
return 0, false, false, fmt.Errorf("%w: %s", ErrTLD, sig.Domain)
}
h, hok := algHash(sig.AlgorithmHash)
if !hok {
return 0, false, false, fmt.Errorf("%w: %q", ErrHashAlgorithmUnknown, sig.AlgorithmHash)
}
t := strings.SplitN(sig.Canonicalization, "/", 2)
switch strings.ToLower(t[0]) {
case "simple":
canonHeaderSimple = true
case "relaxed":
default:
return 0, false, false, fmt.Errorf("%w: header canonicalization %q", ErrCanonicalizationUnknown, sig.Canonicalization)
}
canon := "simple"
if len(t) == 2 {
canon = t[1]
}
switch strings.ToLower(canon) {
case "simple":
canonBodySimple = true
case "relaxed":
default:
return 0, false, false, fmt.Errorf("%w: body canonicalization %q", ErrCanonicalizationUnknown, sig.Canonicalization)
}
// We only recognize query method dns/txt, which is the default. ../rfc/6376:1268
if len(sig.QueryMethods) > 0 {
var dnstxt bool
for _, m := range sig.QueryMethods {
if strings.EqualFold(m, "dns/txt") {
dnstxt = true
break
}
}
if !dnstxt {
return 0, false, false, fmt.Errorf("%w: need dns/txt", ErrQueryMethod)
}
}
return h, canonHeaderSimple, canonBodySimple, nil
}
// lookup the public key in the DNS and verify the signature.
func verifySignature(ctx context.Context, resolver dns.Resolver, sig *Sig, hash crypto.Hash, canonHeaderSimple, canonDataSimple bool, hdrs []header, verifySig []byte, body *bufio.Reader, ignoreTestMode bool) (Status, *Record, error) {
// ../rfc/6376:2604
status, record, _, err := Lookup(ctx, resolver, sig.Selector, sig.Domain)
if err != nil {
// todo: for temporary errors, we could pass on information so caller returns a 4.7.5 ecode, ../rfc/6376:2777
return status, nil, err
}
status, err = verifySignatureRecord(record, sig, hash, canonHeaderSimple, canonDataSimple, hdrs, verifySig, body, ignoreTestMode)
return status, record, err
}
// verify a DKIM signature given the record from dns and signature from the email message.
func verifySignatureRecord(r *Record, sig *Sig, hash crypto.Hash, canonHeaderSimple, canonDataSimple bool, hdrs []header, verifySig []byte, body *bufio.Reader, ignoreTestMode bool) (rstatus Status, rerr error) {
if !ignoreTestMode {
// ../rfc/6376:1558
y := false
for _, f := range r.Flags {
if strings.EqualFold(f, "y") {
y = true
break
}
}
if y {
defer func() {
if rstatus != StatusPass {
rstatus = StatusNone
}
}()
}
}
// ../rfc/6376:2639
if len(r.Hashes) > 0 {
ok := false
for _, h := range r.Hashes {
if strings.EqualFold(h, sig.AlgorithmHash) {
ok = true
break
}
}
if !ok {
return StatusPermerror, fmt.Errorf("%w: dkim dns record expects one of %q, message uses %q", ErrHashAlgNotAllowed, strings.Join(r.Hashes, ","), sig.AlgorithmHash)
}
}
// ../rfc/6376:2651
if !strings.EqualFold(r.Key, sig.AlgorithmSign) {
return StatusPermerror, fmt.Errorf("%w: dkim dns record requires algorithm %q, message has %q", ErrSigAlgMismatch, r.Key, sig.AlgorithmSign)
}
// ../rfc/6376:2645
if r.PublicKey == nil {
return StatusPermerror, ErrKeyRevoked
} else if rsaKey, ok := r.PublicKey.(*rsa.PublicKey); ok && rsaKey.N.BitLen() < 1024 {
// todo: find a reference that supports this.
return StatusPermerror, ErrWeakKey
}
// ../rfc/6376:1541
if !r.ServiceAllowed("email") {
return StatusPermerror, ErrKeyNotForEmail
}
for _, t := range r.Flags {
// ../rfc/6376:1575
// ../rfc/6376:1805
if strings.EqualFold(t, "s") && sig.Identity != nil {
if sig.Identity.Domain.ASCII != sig.Domain.ASCII {
return StatusPermerror, fmt.Errorf("%w: i= identity domain %q must match d= domain %q", ErrDomainIdentityMismatch, sig.Domain.ASCII, sig.Identity.Domain.ASCII)
}
}
}
if sig.Length >= 0 {
// todo future: implement l= parameter in signatures. we don't currently allow this through policy check.
return StatusPermerror, fmt.Errorf("l= (length) parameter in signature not yet implemented")
}
// We first check the signature is with the claimed body hash is valid. Then we
// verify the body hash. In case of invalid signatures, we won't read the entire
// body.
// ../rfc/6376:1700
// ../rfc/6376:2656
dh, err := dataHash(hash.New(), canonHeaderSimple, sig, hdrs, verifySig)
if err != nil {
// Any error is likely an invalid header field in the message, hence permanent error.
return StatusPermerror, fmt.Errorf("calculating data hash: %w", err)
}
switch k := r.PublicKey.(type) {
case *rsa.PublicKey:
if err := rsa.VerifyPKCS1v15(k, hash, dh, sig.Signature); err != nil {
return StatusFail, fmt.Errorf("%w: rsa verification: %s", ErrSigVerify, err)
}
case ed25519.PublicKey:
if ok := ed25519.Verify(k, dh, sig.Signature); !ok {
return StatusFail, fmt.Errorf("%w: ed25519 verification", ErrSigVerify)
}
default:
return StatusPermerror, fmt.Errorf("%w: unrecognized signature algorithm %q", ErrSigAlgorithmUnknown, r.Key)
}
bh, err := bodyHash(hash.New(), canonDataSimple, body)
if err != nil {
// Any error is likely some internal error, hence temporary error.
return StatusTemperror, fmt.Errorf("calculating body hash: %w", err)
}
if !bytes.Equal(sig.BodyHash, bh) {
return StatusFail, fmt.Errorf("%w: signature bodyhash %x != calculated bodyhash %x", ErrBodyhashMismatch, sig.BodyHash, bh)
}
return StatusPass, nil
}
func algHash(s string) (crypto.Hash, bool) {
if strings.EqualFold(s, "sha1") {
return crypto.SHA1, true
} else if strings.EqualFold(s, "sha256") {
return crypto.SHA256, true
}
return 0, false
}
// bodyHash calculates the hash over the body.
func bodyHash(h hash.Hash, canonSimple bool, body *bufio.Reader) ([]byte, error) {
// todo: take l= into account. we don't currently allow it for policy reasons.
var crlf = []byte("\r\n")
if canonSimple {
// ../rfc/6376:864, ensure body ends with exactly one trailing crlf.
ncrlf := 0
for {
buf, err := body.ReadBytes('\n')
if len(buf) == 0 && err == io.EOF {
break
}
if err != nil && err != io.EOF {
return nil, err
}
hascrlf := bytes.HasSuffix(buf, crlf)
if hascrlf {
buf = buf[:len(buf)-2]
}
if len(buf) > 0 {
for ; ncrlf > 0; ncrlf-- {
h.Write(crlf)
}
h.Write(buf)
}
if hascrlf {
ncrlf++
}
}
h.Write(crlf)
} else {
hb := bufio.NewWriter(h)
// We go through the body line by line, replacing WSP with a single space and removing whitespace at the end of lines.
// We stash "empty" lines. If they turn out to be at the end of the file, we must drop them.
stash := &bytes.Buffer{}
var line bool // Whether buffer read is for continuation of line.
var prev byte // Previous byte read for line.
linesEmpty := true // Whether stash contains only empty lines and may need to be dropped.
var bodynonempty bool // Whether body is non-empty, for adding missing crlf.
var hascrlf bool // Whether current/last line ends with crlf, for adding missing crlf.
for {
// todo: should not read line at a time, count empty lines. reduces max memory usage. a message with lots of empty lines can cause high memory use.
buf, err := body.ReadBytes('\n')
if len(buf) == 0 && err == io.EOF {
break
}
if err != nil && err != io.EOF {
return nil, err
}
bodynonempty = true
hascrlf = bytes.HasSuffix(buf, crlf)
if hascrlf {
buf = buf[:len(buf)-2]
// ../rfc/6376:893, "ignore all whitespace at the end of lines".
// todo: what is "whitespace"? it isn't WSP (space and tab), the next line mentions WSP explicitly for another rule. should we drop trailing \r, \n, \v, more?
buf = bytes.TrimRight(buf, " \t")
}
// Replace one or more WSP to a single SP.
for i, c := range buf {
wsp := c == ' ' || c == '\t'
if (i >= 0 || line) && wsp {
if prev == ' ' {
continue
}
prev = ' '
c = ' '
} else {
prev = c
}
if !wsp {
linesEmpty = false
}
stash.WriteByte(c)
}
if hascrlf {
stash.Write(crlf)
}
line = !hascrlf
if !linesEmpty {
hb.Write(stash.Bytes())
stash.Reset()
linesEmpty = true
}
}
// ../rfc/6376:886
// Only for non-empty bodies without trailing crlf do we add the missing crlf.
if bodynonempty && !hascrlf {
hb.Write(crlf)
}
hb.Flush()
}
return h.Sum(nil), nil
}
func dataHash(h hash.Hash, canonSimple bool, sig *Sig, hdrs []header, verifySig []byte) ([]byte, error) {
headers := ""
revHdrs := map[string][]header{}
for _, h := range hdrs {
revHdrs[h.lkey] = append([]header{h}, revHdrs[h.lkey]...)
}
for _, key := range sig.SignedHeaders {
lkey := strings.ToLower(key)
h := revHdrs[lkey]
if len(h) == 0 {
continue
}
revHdrs[lkey] = h[1:]
s := string(h[0].raw)
if canonSimple {
// ../rfc/6376:823
// Add unmodified.
headers += s
} else {
ch, err := relaxedCanonicalHeaderWithoutCRLF(s)
if err != nil {
return nil, fmt.Errorf("canonicalizing header: %w", err)
}
headers += ch + "\r\n"
}
}
// ../rfc/6376:2377, canonicalization does not apply to the dkim-signature header.
h.Write([]byte(headers))
dkimSig := verifySig
if !canonSimple {
ch, err := relaxedCanonicalHeaderWithoutCRLF(string(verifySig))
if err != nil {
return nil, fmt.Errorf("canonicalizing DKIM-Signature header: %w", err)
}
dkimSig = []byte(ch)
}
h.Write(dkimSig)
return h.Sum(nil), nil
}
// a single header, can be multiline.
func relaxedCanonicalHeaderWithoutCRLF(s string) (string, error) {
// ../rfc/6376:831
t := strings.SplitN(s, ":", 2)
if len(t) != 2 {
return "", fmt.Errorf("%w: invalid header %q", ErrHeaderMalformed, s)
}
// Unfold, we keep the leading WSP on continuation lines and fix it up below.
v := strings.ReplaceAll(t[1], "\r\n", "")
// Replace one or more WSP to a single SP.
var nv []byte
var prev byte
for i, c := range []byte(v) {
if i >= 0 && c == ' ' || c == '\t' {
if prev == ' ' {
continue
}
prev = ' '
c = ' '
} else {
prev = c
}
nv = append(nv, c)
}
ch := strings.ToLower(strings.TrimRight(t[0], " \t")) + ":" + strings.Trim(string(nv), " \t")
return ch, nil
}
type header struct {
key string // Key in original case.
lkey string // Key in lower-case, for canonical case.
value []byte // Literal header value, possibly spanning multiple lines, not modified in any way, including crlf, excluding leading key and colon.
raw []byte // Like value, but including original leading key and colon. Ready for use as simple header canonicalized use.
}
func parseHeaders(br *bufio.Reader) ([]header, int, error) {
var o int
var l []header
var key, lkey string
var value []byte
var raw []byte
for {
line, err := readline(br)
if err != nil {
return nil, 0, err
}
o += len(line)
if bytes.Equal(line, []byte("\r\n")) {
break
}
if line[0] == ' ' || line[0] == '\t' {
if len(l) == 0 && key == "" {
return nil, 0, fmt.Errorf("malformed message, starts with space/tab")
}
value = append(value, line...)
raw = append(raw, line...)
continue
}
if key != "" {
l = append(l, header{key, lkey, value, raw})
}
t := bytes.SplitN(line, []byte(":"), 2)
if len(t) != 2 {
return nil, 0, fmt.Errorf("malformed message, header without colon")
}
key = strings.TrimRight(string(t[0]), " \t") // todo: where is this specified?
// Check for valid characters. ../rfc/5322:1689 ../rfc/6532:193
for _, c := range key {
if c <= ' ' || c >= 0x7f {
return nil, 0, fmt.Errorf("invalid header field name")
}
}
if key == "" {
return nil, 0, fmt.Errorf("empty header key")
}
lkey = strings.ToLower(key)
value = append([]byte{}, t[1]...)
raw = append([]byte{}, line...)
}
if key != "" {
l = append(l, header{key, lkey, value, raw})
}
return l, o, nil
}
func readline(r *bufio.Reader) ([]byte, error) {
var buf []byte
for {
line, err := r.ReadBytes('\n')
if err != nil {
return nil, err
}
if bytes.HasSuffix(line, []byte("\r\n")) {
if len(buf) == 0 {
return line, nil
}
return append(buf, line...), nil
}
buf = append(buf, line...)
}
}

702
dkim/dkim_test.go Normal file
View File

@ -0,0 +1,702 @@
package dkim
import (
"bufio"
"bytes"
"context"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"strings"
"testing"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
)
func policyOK(sig *Sig) error {
return nil
}
func parseRSAKey(t *testing.T, rsaText string) *rsa.PrivateKey {
rsab, _ := pem.Decode([]byte(rsaText))
if rsab == nil {
t.Fatalf("no pem in privKey")
}
key, err := x509.ParsePKCS8PrivateKey(rsab.Bytes)
if err != nil {
t.Fatalf("parsing private key: %s", err)
}
return key.(*rsa.PrivateKey)
}
func getRSAKey(t *testing.T) *rsa.PrivateKey {
// Generated with:
// openssl genrsa -out pkcs1.pem 2048
// openssl pkcs8 -topk8 -inform pem -in pkcs1.pem -outform pem -nocrypt -out pkcs8.pem
const rsaText = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu7iTF/AAvJQ3U
WRlcXd+n6HXOSYvmDlqjLsuCKn6/T+Ma0ZtobCRfzyXh5pFQBCHffW6fpEzJs/2o
+e896zb1QKjD8Xxsjarjdw1iXzgMj/lhDGWyNyUHC34+k77UfpQBZgPLvZHyYyQG
sVMzzmvURE+GMFmXYUiGI581PdCx4bNba/4gYQnc/eqQ8oX0T//2RdRqdhdDM2d7
CYALtkxKetH1F+Rz7XDjFmI3GjPs1KwVdh+Cl8kejThi0SVxXpqnoqB2WGsr/lGG
GxsxcpLb/+KWFjI0go3OJjMaxFCmhB0pGdW8I7kNwNrZsCdSvmjMDojNuegx6WMg
/T7go3CvAgMBAAECggEAQA3AlmSDtr+lNDvZ7voKwwN6W6qPmRJpevZQG54u4iPA
/5mAA/kRSqnh77mLPRb+RkU6RCeX3IXVXNIEGhKugZiHE5Sx4FfxmrAFzR8buXHg
uXoeJOdPXiiFtilIh6u/y1FNE4YbUnud/fthgYdU8Zl/2x2KOMWtFj0l94tmhzOI
b2y8/U8r85anI5XGYuzRCqKS1WskXhkXH8LZUB+9yAxX7V5ysgxjofM4FW8ns7yj
K4cBS8KY2v3t7TZ4FgwkAhPcTfBc/E2UWT1Ztmr+18LFV5bqI8g2YlN+BgCxU7U/
1tawxqFhs+xowEpzNwAvjAIPpptIRiY1rz7sBB9g5QKBgQDLo/5rTUwNOPR9dYvA
+DYUSCfxvNamI4GI66AgwOeN8O+W+dRDF/Ewbk/SJsBPSLIYzEiQ2uYKcNEmIjo+
7WwSCJZjKujovw77s9JAHexhpd8uLD2w9l3KeTg41LEYm2uVwoXWEHYSYJ9Ynz0M
PWxvi2Hm0IoQ7gJIfxng/wIw3QKBgQDb6GFvPH/OTs40+dopwtm3irmkBAmT8N0b
3TpehONCOiL4GPxmn2DN6ELhHFV27Jj/1CfpGVbcBlaS1xYUGUGsB9gYukhdaBST
KGHRoeZDcf0gaQLKG15EEfFOvcKI9aGljV8FdFfG+Z4fW3LA8khvpvjLLkv1A1jM
MrEBthco+wKBgD45EM9GohtUMNh450gCT7voxFPICKphJP5qSNZZOyeS3BJ8qdAK
a8cJndgvwQk4xDpxiSbBzBKaoD2Prc52i1QDTbhlbx9W6cQdEPxIaGb54PThzcPZ
s5Tfbz9mNeq36qqq8mwTQZCh926D0YqA5jY7F6IITHeZ0hbGx2iJYuj9AoGARIyK
ms8kE95y3wanX+8ySMmAlsT/a1NgyUfL4xzPbpyKvAWl4CN8XJMzDdL0PS8BfnXW
vw28CrgbEojjg/5ff02uqf6fgiZoi3rCC0PJcGq++fRh/zhKyTNCokX6txDCg8Wu
wheDKS40gRfTjJu5wrwsv8E9wjF546VFkf/99jMCgYEAm/x+kEfWKuzx8pQT66TY
pxnC41upJOO1htTHNIN24J7XrrFI5+OZq90G+t/VgWX08Z8RlhejX+ukBf+SRu3u
5VMGcAs4px+iECX/FHo21YQFnrmArN1zdFxPU3rBWoBueqmGO6FT0HBbKzTuS7N0
7fIv3GQqImz3+ZbYWlXfkPI=
-----END PRIVATE KEY-----`
return parseRSAKey(t, rsaText)
}
func getWeakRSAKey(t *testing.T) *rsa.PrivateKey {
const rsaText = `-----BEGIN PRIVATE KEY-----
MIIBUwIBADANBgkqhkiG9w0BAQEFAASCAT0wggE5AgEAAkEAsQo3ATJAZ4aAZz+l
ndXl27ODOY+49DjYxwhgtg+OU8A1WEYCfWaZ7ozYtpsqH8GNFvlKtK38eKbdDuLw
gsFYMQIDAQABAkBwstb2/P1Aqb9deoe8JOiw5eJYJySO2w0sDio6W0a4Cqi7XQ7r
/yZ1gOp+ZnShX/sJq0Pd16UkJUUEtEPoZyptAiEA4KLP8pz/9R0t7Envqph1oVjQ
CVDIL/UKRmdnMiwwDosCIQDJwiu08UgNNeliAygbkC2cdszjf4a3laGmYbfWrtAn
swIgUBfc+w0degDgadpm2LWpY1DuRBQIfIjrE/U0Z0A4FkcCIHxEuoLycjygziTu
aM/BWDac/cnKDIIbCbvfSEpU1iT9AiBsbkAcYCQ8mR77BX6gZKEc74nSce29gmR7
mtrKWknTDQ==
-----END PRIVATE KEY-----`
return parseRSAKey(t, rsaText)
}
func TestParseSignature(t *testing.T) {
// Domain name must always be A-labels, not U-labels. We do allow localpart with non-ascii.
hdr := `DKIM-Signature: v=1; a=rsa-sha256; d=xn--h-bga.mox.example; s=xn--yr2021-pua;
i=møx@xn--h-bga.mox.example; t=1643719203; h=From:To:Cc:Bcc:Reply-To:
References:In-Reply-To:Subject:Date:Message-ID:Content-Type:From:To:Subject:
Date:Message-ID:Content-Type;
bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=; b=dtgAOl71h/dNPQrmZTi3SBVkm+
EjMnF7sWGT123fa5g+m6nGpPue+I+067wwtkWQhsedbDkqT7gZb5WaG5baZsr9e/XpJ/iX4g6YXpr
07aLY8eF9jazcGcRCVCqLtyq0UJQ2Oz/ML74aYu1beh3jXsoI+k3fJ+0/gKSVC7enCFpNe1HhbXVS
4HRy/Rw261OEIy2e20lyPT4iDk2oODabzYa28HnXIciIMELjbc/sSawG68SAnhwdkWBrRzBDMCCHm
wvkmgDsVJWtdzjJqjxK2mYVxBMJT0lvsutXgYQ+rr6BLtjHsOb8GMSbQGzY5SJ3N8TP02pw5OykBu
B/aHff1A==
`
smtputf8 := true
_, _, err := parseSignature([]byte(strings.ReplaceAll(hdr, "\n", "\r\n")), smtputf8)
if err != nil {
t.Fatalf("parsing signature: %s", err)
}
}
func TestVerifyRSA(t *testing.T) {
message := strings.ReplaceAll(`Return-Path: <mechiel@ueber.net>
X-Original-To: mechiel@ueber.net
Delivered-To: mechiel@ueber.net
Received: from [IPV6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0] (unknown [IPv6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0])
by koriander.ueber.net (Postfix) with ESMTPSA id E119EDEB0B
for <mechiel@ueber.net>; Fri, 10 Dec 2021 20:09:08 +0100 (CET)
DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=ueber.net;
s=koriander; t=1639163348;
bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=;
h=Date:To:From:Subject:From;
b=rpWruWprs2TB7/MnulA2n2WtfUIfrrnAvRoSrip1ruX5ORN4AOYPPMmk/gGBDdc6O
grRpSsNzR9BrWcooYfbNfSbl04nPKMp0acsZGfpvkj0+mqk5b8lqZs3vncG1fHlQc7
0KXfnAHyEs7bjyKGbrw2XG1p/EDoBjIjUsdpdCAtamMGv3A3irof81oSqvwvi2KQks
17aB1YAL9Xzkq9ipo1aWvDf2W6h6qH94YyNocyZSVJ+SlVm3InNaF8APkV85wOm19U
9OW81eeuQbvSPcQZJVOmrWzp7XKHaXH0MYE3+hdH/2VtpCnPbh5Zj9SaIgVbaN6NPG
Ua0E07rwC86sg==
Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
Date: Fri, 10 Dec 2021 20:09:08 +0100
MIME-Version: 1.0
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
Thunderbird/91.4.0
Content-Language: nl
To: mechiel@ueber.net
From: Mechiel Lukkien <mechiel@ueber.net>
Subject: test
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
test
`, "\n", "\r\n")
resolver := dns.MockResolver{
TXT: map[string][]string{
"koriander._domainkey.ueber.net.": {"v=DKIM1; k=rsa; s=email; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy3Z9ffZe8gUTJrdGuKj6IwEembmKYpp0jMa8uhudErcI4gFVUaFiiRWxc4jP/XR9NAEv3XwHm+CVcHu+L/n6VWt6g59U7vHXQicMfKGmEp2VplsgojNy/Y5X9HdVYM0azsI47NcJCDW9UVfeOHdOSgFME4F8dNtUKC4KTB2d1pqj/yixz+V8Sv8xkEyPfSRHcNXIw0LvelqJ1MRfN3hO/3uQSVrPYYk4SyV0b6wfnkQs28fpiIpGQvzlGI5WkrdOQT5k4YHaEvZDLNdwiMeVZOEL7dDoFs2mQsovm+tH0StUAZTnr61NLVFfD5V6Ip1V9zVtspPHvYSuOWwyArFZ9QIDAQAB"},
},
}
results, err := Verify(context.Background(), resolver, false, policyOK, strings.NewReader(message), false)
if err != nil {
t.Fatalf("dkim verify: %v", err)
}
if len(results) != 1 || results[0].Status != StatusPass {
t.Fatalf("verify: unexpected results %v", results)
}
}
func TestVerifyEd25519(t *testing.T) {
// ../rfc/8463:287
message := strings.ReplaceAll(`DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.
`, "\n", "\r\n")
resolver := dns.MockResolver{
TXT: map[string][]string{
"brisbane._domainkey.football.example.com.": {"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
"test._domainkey.football.example.com.": {"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB"},
},
}
results, err := Verify(context.Background(), resolver, false, policyOK, strings.NewReader(message), false)
if err != nil {
t.Fatalf("dkim verify: %v", err)
}
if len(results) != 2 || results[0].Status != StatusPass || results[1].Status != StatusPass {
t.Fatalf("verify: unexpected results %#v", results)
}
}
func TestSign(t *testing.T) {
message := strings.ReplaceAll(`Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
Date: Fri, 10 Dec 2021 20:09:08 +0100
MIME-Version: 1.0
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
Thunderbird/91.4.0
Content-Language: nl
To: mechiel@ueber.net
From: Mechiel Lukkien <mechiel@ueber.net>
Subject: test
test
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
test
`, "\n", "\r\n")
rsaKey := getRSAKey(t)
ed25519Key := ed25519.NewKeyFromSeed(make([]byte, 32))
selrsa := config.Selector{
HashEffective: "sha256",
Key: rsaKey,
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
Domain: dns.Domain{ASCII: "testrsa"},
}
// Now with sha1 and relaxed canonicalization.
selrsa2 := config.Selector{
HashEffective: "sha1",
Key: rsaKey,
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
Domain: dns.Domain{ASCII: "testrsa2"},
}
selrsa2.Canonicalization.HeaderRelaxed = true
selrsa2.Canonicalization.BodyRelaxed = true
// Ed25519 key.
seled25519 := config.Selector{
HashEffective: "sha256",
Key: ed25519Key,
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
Domain: dns.Domain{ASCII: "tested25519"},
}
// Again ed25519, but without sealing headers. Use sha256 again, for reusing the body hash from the previous dkim-signature.
seled25519b := config.Selector{
HashEffective: "sha256",
Key: ed25519Key,
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,Subject,Date", ","),
DontSealHeaders: true,
Domain: dns.Domain{ASCII: "tested25519b"},
}
dkimConf := config.DKIM{
Selectors: map[string]config.Selector{
"testrsa": selrsa,
"testrsa2": selrsa2,
"tested25519": seled25519,
"tested25519b": seled25519b,
},
Sign: []string{"testrsa", "testrsa2", "tested25519", "tested25519b"},
}
ctx := context.Background()
headers, err := Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(message))
if err != nil {
t.Fatalf("sign: %v", err)
}
makeRecord := func(k string, publicKey any) string {
tr := &Record{
Version: "DKIM1",
Key: k,
PublicKey: publicKey,
Flags: []string{"s"},
}
txt, err := tr.Record()
if err != nil {
t.Fatalf("making dns txt record: %s", err)
}
//log.Infof("txt record: %s", txt)
return txt
}
resolver := dns.MockResolver{
TXT: map[string][]string{
"testrsa._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
"testrsa2._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
"tested25519._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
"tested25519b._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
},
}
nmsg := headers + message
results, err := Verify(ctx, resolver, false, policyOK, strings.NewReader(nmsg), false)
if err != nil {
t.Fatalf("verify: %s", err)
}
if len(results) != 4 || results[0].Status != StatusPass || results[1].Status != StatusPass || results[2].Status != StatusPass || results[3].Status != StatusPass {
t.Fatalf("verify: unexpected results %v\nheaders:\n%s", results, headers)
}
//log.Infof("headers:%s", headers)
//log.Infof("nmsg\n%s", nmsg)
// Multiple From headers.
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("From: <mjl@mox.example>\r\nFrom: <mjl@mox.example>\r\n\r\ntest"))
if !errors.Is(err, ErrFrom) {
t.Fatalf("sign, got err %v, expected ErrFrom", err)
}
// No From header.
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("Brom: <mjl@mox.example>\r\n\r\ntest"))
if !errors.Is(err, ErrFrom) {
t.Fatalf("sign, got err %v, expected ErrFrom", err)
}
// Malformed headers.
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(":\r\n\r\ntest"))
if !errors.Is(err, ErrHeaderMalformed) {
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
}
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(" From:<mjl@mox.example>\r\n\r\ntest"))
if !errors.Is(err, ErrHeaderMalformed) {
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
}
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("Frøm:<mjl@mox.example>\r\n\r\ntest"))
if !errors.Is(err, ErrHeaderMalformed) {
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
}
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("From:<mjl@mox.example>"))
if !errors.Is(err, ErrHeaderMalformed) {
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
}
}
func TestVerify(t *testing.T) {
// We do many Verify calls, each time starting out with a valid configuration, then
// we modify one thing to trigger an error, which we check for.
const message = `From: <mjl@mox.example>
To: <other@mox.example>
Subject: test
Date: Fri, 10 Dec 2021 20:09:08 +0100
Message-ID: <test@mox.example>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
test
`
key := ed25519.NewKeyFromSeed(make([]byte, 32))
var resolver dns.MockResolver
var record *Record
var recordTxt string
var msg string
var sel config.Selector
var dkimConf config.DKIM
var policy func(*Sig) error
var signed bool
var signDomain dns.Domain
prepare := func() {
t.Helper()
policy = DefaultPolicy
signDomain = dns.Domain{ASCII: "mox.example"}
record = &Record{
Version: "DKIM1",
Key: "ed25519",
PublicKey: key.Public(),
Flags: []string{"s"},
}
txt, err := record.Record()
if err != nil {
t.Fatalf("making dns txt record: %s", err)
}
recordTxt = txt
resolver = dns.MockResolver{
TXT: map[string][]string{
"test._domainkey.mox.example.": {txt},
},
}
sel = config.Selector{
HashEffective: "sha256",
Key: key,
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
Domain: dns.Domain{ASCII: "test"},
}
dkimConf = config.DKIM{
Selectors: map[string]config.Selector{
"test": sel,
},
Sign: []string{"test"},
}
msg = message
signed = false
}
sign := func() {
t.Helper()
msg = strings.ReplaceAll(msg, "\n", "\r\n")
headers, err := Sign(context.Background(), "mjl", signDomain, dkimConf, false, strings.NewReader(msg))
if err != nil {
t.Fatalf("sign: %v", err)
}
msg = headers + msg
signed = true
}
test := func(expErr error, expStatus Status, expResultErr error, mod func()) {
t.Helper()
prepare()
mod()
if !signed {
sign()
}
results, err := Verify(context.Background(), resolver, true, policy, strings.NewReader(msg), false)
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
t.Fatalf("got verify error %v, expected %v", err, expErr)
}
if expStatus != "" && (len(results) == 0 || results[0].Status != expStatus) {
var status Status
if len(results) > 0 {
status = results[0].Status
}
t.Fatalf("got status %q, expected %q", status, expStatus)
}
var resultErr error
if len(results) > 0 {
resultErr = results[0].Err
}
if (resultErr == nil) != (expResultErr == nil) || resultErr != nil && !errors.Is(resultErr, expResultErr) {
t.Fatalf("got result error %v, expected %v", resultErr, expResultErr)
}
}
test(nil, StatusPass, nil, func() {})
// Cannot parse message, so not much more to do.
test(ErrHeaderMalformed, "", nil, func() {
sign()
msg = ":\r\n\r\n" // Empty header key.
})
// From Lookup.
// No DKIM record. ../rfc/6376:2608
test(nil, StatusPermerror, ErrNoRecord, func() {
resolver.TXT = nil
})
// DNS request is failing temporarily.
test(nil, StatusTemperror, ErrDNS, func() {
resolver.Fail = map[dns.Mockreq]struct{}{
{Type: "txt", Name: "test._domainkey.mox.example."}: {},
}
})
// Claims to be DKIM through v=, but cannot be parsed. ../rfc/6376:2621
test(nil, StatusPermerror, ErrSyntax, func() {
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {"v=DKIM1; bogus"},
}
})
// Not a DKIM record. ../rfc/6376:2621
test(nil, StatusTemperror, ErrSyntax, func() {
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {"bogus"},
}
})
// Multiple dkim records. ../rfc/6376:1609
test(nil, StatusTemperror, ErrMultipleRecords, func() {
resolver.TXT["test._domainkey.mox.example."] = []string{recordTxt, recordTxt}
})
// Invalid DKIM-Signature header. ../rfc/6376:2503
test(nil, StatusPermerror, errSigMissingTag, func() {
msg = strings.ReplaceAll("DKIM-Signature: v=1\n"+msg, "\n", "\r\n")
signed = true
})
// Signature has valid syntax, but parameters aren't acceptable.
// "From" not signed. ../rfc/6376:2546
test(nil, StatusPermerror, ErrFrom, func() {
sign()
// Remove "from" from signed headers (h=).
msg = strings.ReplaceAll(msg, ":From:", ":")
msg = strings.ReplaceAll(msg, "=From:", "=")
})
// todo: check expired signatures with StatusPermerror and ErrSigExpired. ../rfc/6376:2550
// Domain in signature is higher-level than organizational domain. ../rfc/6376:2554
test(nil, StatusPermerror, ErrTLD, func() {
// Pretend to sign as .com
msg = strings.ReplaceAll(msg, "From: <mjl@mox.example>\n", "From: <mjl@com>\n")
signDomain = dns.Domain{ASCII: "com"}
resolver.TXT = map[string][]string{
"test._domainkey.com.": {recordTxt},
}
})
// Unknown hash algorithm.
test(nil, StatusPermerror, ErrHashAlgorithmUnknown, func() {
sign()
msg = strings.ReplaceAll(msg, "sha256", "sha257")
})
// Unknown canonicalization.
test(nil, StatusPermerror, ErrCanonicalizationUnknown, func() {
sel.Canonicalization.HeaderRelaxed = true
sel.Canonicalization.BodyRelaxed = true
dkimConf.Selectors = map[string]config.Selector{
"test": sel,
}
sign()
msg = strings.ReplaceAll(msg, "relaxed/relaxed", "bogus/bogus")
})
// Query methods without dns/txt. ../rfc/6376:1268
test(nil, StatusPermerror, ErrQueryMethod, func() {
sign()
msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: q=other;")
})
// Unacceptable through policy. ../rfc/6376:2560
test(nil, StatusPolicy, ErrPolicy, func() {
sign()
msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: l=1;")
})
// Hash algorithm not allowed by DNS record. ../rfc/6376:2639
test(nil, StatusPermerror, ErrHashAlgNotAllowed, func() {
recordTxt += ";h=sha1"
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {recordTxt},
}
})
// Signature algorithm mismatch. ../rfc/6376:2651
test(nil, StatusPermerror, ErrSigAlgMismatch, func() {
record.PublicKey = getRSAKey(t).Public()
record.Key = "rsa"
txt, err := record.Record()
if err != nil {
t.Fatalf("making dns txt record: %s", err)
}
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {txt},
}
})
// Empty public key means revoked key. ../rfc/6376:2645
test(nil, StatusPermerror, ErrKeyRevoked, func() {
record.PublicKey = nil
txt, err := record.Record()
if err != nil {
t.Fatalf("making dns txt record: %s", err)
}
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {txt},
}
})
// We refuse rsa keys smaller than 1024 bits.
test(nil, StatusPermerror, ErrWeakKey, func() {
key := getWeakRSAKey(t)
record.Key = "rsa"
record.PublicKey = key.Public()
txt, err := record.Record()
if err != nil {
t.Fatalf("making dns txt record: %s", err)
}
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {txt},
}
sel.Key = key
dkimConf.Selectors = map[string]config.Selector{
"test": sel,
}
})
// Key not allowed for email by DNS record. ../rfc/6376:1541
test(nil, StatusPermerror, ErrKeyNotForEmail, func() {
recordTxt += ";s=other"
resolver.TXT = map[string][]string{
"test._domainkey.mox.example.": {recordTxt},
}
})
// todo: Record has flag "s" but identity does not have exact domain match. Cannot currently easily implement this test because Sign() always uses the same domain. ../rfc/6376:1575
// Wrong signature, different datahash, and thus signature.
test(nil, StatusFail, ErrSigVerify, func() {
sign()
msg = strings.ReplaceAll(msg, "Subject: test\r\n", "Subject: modified header\r\n")
})
// Signature is correct for bodyhash, but the body has changed.
test(nil, StatusFail, ErrBodyhashMismatch, func() {
sign()
msg = strings.ReplaceAll(msg, "\r\ntest\r\n", "\r\nmodified body\r\n")
})
// Check that last-occurring header field is used.
test(nil, StatusFail, ErrSigVerify, func() {
sel.DontSealHeaders = true
dkimConf.Selectors = map[string]config.Selector{
"test": sel,
}
sign()
msg = strings.ReplaceAll(msg, "\r\n\r\n", "\r\nsubject: another\r\n\r\n")
})
test(nil, StatusPass, nil, func() {
sel.DontSealHeaders = true
dkimConf.Selectors = map[string]config.Selector{
"test": sel,
}
sign()
msg = "subject: another\r\n" + msg
})
}
func TestBodyHash(t *testing.T) {
simpleGot, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader("")))
if err != nil {
t.Fatalf("body hash, simple, empty string: %s", err)
}
simpleWant := base64Decode("frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=")
if !bytes.Equal(simpleGot, simpleWant) {
t.Fatalf("simple body hash for empty string, got %s, expected %s", base64Encode(simpleGot), base64Encode(simpleWant))
}
relaxedGot, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader("")))
if err != nil {
t.Fatalf("body hash, relaxed, empty string: %s", err)
}
relaxedWant := base64Decode("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
if !bytes.Equal(relaxedGot, relaxedWant) {
t.Fatalf("relaxed body hash for empty string, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
}
compare := func(a, b []byte) {
t.Helper()
if !bytes.Equal(a, b) {
t.Fatalf("hash not equal")
}
}
// NOTE: the trailing space in the strings below are part of the test for canonicalization.
// ../rfc/6376:936
exampleIn := strings.ReplaceAll(` c
d e
`, "\n", "\r\n")
relaxedOut := strings.ReplaceAll(` c
d e
`, "\n", "\r\n")
relaxedBh, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(exampleIn)))
if err != nil {
t.Fatalf("bodyhash: %s", err)
}
relaxedOutHash := sha256.Sum256([]byte(relaxedOut))
compare(relaxedBh, relaxedOutHash[:])
simpleOut := strings.ReplaceAll(` c
d e
`, "\n", "\r\n")
simpleBh, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader(exampleIn)))
if err != nil {
t.Fatalf("bodyhash: %s", err)
}
simpleOutHash := sha256.Sum256([]byte(simpleOut))
compare(simpleBh, simpleOutHash[:])
// ../rfc/8463:343
relaxedBody := strings.ReplaceAll(`Hi.
We lost the game. Are you hungry yet?
Joe.
`, "\n", "\r\n")
relaxedGot, err = bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(relaxedBody)))
if err != nil {
t.Fatalf("body hash, relaxed, ed25519 example: %s", err)
}
relaxedWant = base64Decode("2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=")
if !bytes.Equal(relaxedGot, relaxedWant) {
t.Fatalf("relaxed body hash for ed25519 example, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
}
}
func base64Decode(s string) []byte {
buf, err := base64.StdEncoding.DecodeString(s)
if err != nil {
panic(err)
}
return buf
}
func base64Encode(buf []byte) string {
return base64.StdEncoding.EncodeToString(buf)
}

25
dkim/fuzz_test.go Normal file
View File

@ -0,0 +1,25 @@
package dkim
import (
"testing"
)
func FuzzParseSignature(f *testing.F) {
f.Add([]byte(""))
f.Fuzz(func(t *testing.T, buf []byte) {
parseSignature(buf, false)
})
}
func FuzzParseRecord(f *testing.F) {
f.Add("")
f.Add("v=DKIM1; p=bad")
f.Fuzz(func(t *testing.T, s string) {
r, _, err := ParseRecord(s)
if err == nil {
if _, err := r.Record(); err != nil {
t.Errorf("r.Record() for parsed record %s, %#v: %s", s, r, err)
}
}
})
}

474
dkim/parser.go Normal file
View File

@ -0,0 +1,474 @@
package dkim
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
)
type parseErr string
func (e parseErr) Error() string {
return string(e)
}
var _ error = parseErr("")
type parser struct {
s string
o int // Offset into s.
tracked string // All data consumed, except when "drop" is true. To be set by caller when parsing the value for "b=".
drop bool
smtputf8 bool // If set, allow characters > 0x7f.
}
func (p *parser) xerrorf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if p.o < len(p.s) {
msg = fmt.Sprintf("%s (leftover %q)", msg, p.s[p.o:])
}
panic(parseErr(msg))
}
func (p *parser) track(s string) {
if !p.drop {
p.tracked += s
}
}
func (p *parser) hasPrefix(s string) bool {
return strings.HasPrefix(p.s[p.o:], s)
}
func (p *parser) xtaken(n int) string {
r := p.s[p.o : p.o+n]
p.o += n
p.track(r)
return r
}
func (p *parser) xtakefn(fn func(c rune, i int) bool) string {
for i, c := range p.s[p.o:] {
if !fn(c, i) {
return p.xtaken(i)
}
}
return p.xtaken(len(p.s) - p.o)
}
func (p *parser) empty() bool {
return p.o >= len(p.s)
}
func (p *parser) xnonempty() {
if p.o >= len(p.s) {
p.xerrorf("expected at least 1 more char")
}
}
func (p *parser) xtakefn1(fn func(c rune, i int) bool) string {
p.xnonempty()
for i, c := range p.s[p.o:] {
if !fn(c, i) {
if i == 0 {
p.xerrorf("expected at least 1 char")
}
return p.xtaken(i)
}
}
return p.xtaken(len(p.s) - p.o)
}
func (p *parser) wsp() {
p.xtakefn(func(c rune, i int) bool {
return c == ' ' || c == '\t'
})
}
func (p *parser) fws() {
p.wsp()
if p.hasPrefix("\r\n ") || p.hasPrefix("\r\n\t") {
p.xtaken(3)
p.wsp()
}
}
// peekfws returns whether remaining text starts with s, optionally prefix with fws.
func (p *parser) peekfws(s string) bool {
o := p.o
p.fws()
r := p.hasPrefix(s)
p.o = o
return r
}
func (p *parser) xtake(s string) string {
if !strings.HasPrefix(p.s[p.o:], s) {
p.xerrorf("expected %q", s)
}
return p.xtaken(len(s))
}
func (p *parser) take(s string) bool {
if strings.HasPrefix(p.s[p.o:], s) {
p.o += len(s)
p.track(s)
return true
}
return false
}
// ../rfc/6376:657
func (p *parser) xtagName() string {
return p.xtakefn1(func(c rune, i int) bool {
return isalpha(c) || i > 0 && (isdigit(c) || c == '_')
})
}
func (p *parser) xalgorithm() (string, string) {
// ../rfc/6376:1046
xtagx := func(c rune, i int) bool {
return isalpha(c) || i > 0 && isdigit(c)
}
algk := p.xtakefn1(xtagx)
p.xtake("-")
algv := p.xtakefn1(xtagx)
return algk, algv
}
// fws in value is ignored. empty/no base64 characters is valid.
// ../rfc/6376:1021
// ../rfc/6376:1076
func (p *parser) xbase64() []byte {
s := ""
p.xtakefn(func(c rune, i int) bool {
if isalphadigit(c) || c == '+' || c == '/' || c == '=' {
s += string(c)
return true
}
if c == ' ' || c == '\t' {
return true
}
rem := p.s[p.o+i:]
if strings.HasPrefix(rem, "\r\n ") || strings.HasPrefix(rem, "\r\n\t") {
return true
}
if (strings.HasPrefix(rem, "\n ") || strings.HasPrefix(rem, "\n\t")) && p.o+i-1 > 0 && p.s[p.o+i-1] == '\r' {
return true
}
return false
})
buf, err := base64.StdEncoding.DecodeString(s)
if err != nil {
p.xerrorf("decoding base64: %v", err)
}
return buf
}
// parses canonicalization in original case.
func (p *parser) xcanonical() string {
// ../rfc/6376:1100
s := p.xhyphenatedWord()
if p.take("/") {
return s + "/" + p.xhyphenatedWord()
}
return s
}
func (p *parser) xdomain() dns.Domain {
subdomain := func(c rune, i int) bool {
// domain names must always be a-labels, ../rfc/6376:1115 ../rfc/6376:1187 ../rfc/6376:1303
// todo: add a "lax" mode where underscore is allowed if this is a selector? seen in the wild, but invalid: ../rfc/6376:581 ../rfc/5321:2303
return isalphadigit(c) || (i > 0 && c == '-' && p.o+1 < len(p.s))
}
s := p.xtakefn1(subdomain)
for p.hasPrefix(".") {
s += p.xtake(".") + p.xtakefn1(subdomain)
}
d, err := dns.ParseDomain(s)
if err != nil {
p.xerrorf("parsing domain %q: %s", s, err)
}
return d
}
func (p *parser) xhdrName() string {
// ../rfc/6376:473
// ../rfc/5322:1689
// BNF for hdr-name (field-name) allows ";", but DKIM disallows unencoded semicolons. ../rfc/6376:643
return p.xtakefn1(func(c rune, i int) bool {
return c > ' ' && c < 0x7f && c != ':' && c != ';'
})
}
func (p *parser) xsignedHeaderFields() []string {
// ../rfc/6376:1157
l := []string{p.xhdrName()}
for p.peekfws(":") {
p.fws()
p.xtake(":")
p.fws()
l = append(l, p.xhdrName())
}
return l
}
func (p *parser) xauid() Identity {
// ../rfc/6376:1192
// Localpart is optional.
if p.take("@") {
return Identity{Domain: p.xdomain()}
}
lp := p.xlocalpart()
p.xtake("@")
dom := p.xdomain()
return Identity{&lp, dom}
}
// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
func (p *parser) xlocalpart() smtp.Localpart {
// ../rfc/6376:434
// ../rfc/5321:2316
var s string
if p.hasPrefix(`"`) {
s = p.xquotedString()
} else {
s = p.xatom()
for p.take(".") {
s += "." + p.xatom()
}
}
// todo: have a strict parser that only allows the actual max of 64 bytes. some services have large localparts because of generated (bounce) addresses.
if len(s) > 128 {
// ../rfc/5321:3486
p.xerrorf("localpart longer than 64 octets")
}
return smtp.Localpart(s)
}
func (p *parser) xquotedString() string {
p.xtake(`"`)
var s string
var esc bool
for {
c := p.xchar()
if esc {
if c >= ' ' && c < 0x7f {
s += string(c)
esc = false
continue
}
p.xerrorf("invalid localpart, bad escaped char %c", c)
}
if c == '\\' {
esc = true
continue
}
if c == '"' {
return s
}
if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || (c > 0x7f && p.smtputf8) {
s += string(c)
continue
}
p.xerrorf("invalid localpart, invalid character %c", c)
}
}
func (p *parser) xchar() rune {
// We are careful to track invalid utf-8 properly.
if p.empty() {
p.xerrorf("need another character")
}
var r rune
var o int
for i, c := range p.s[p.o:] {
if i > 0 {
o = i
break
}
r = c
}
if o == 0 {
p.track(p.s[p.o:])
p.o = len(p.s)
} else {
p.track(p.s[p.o : p.o+o])
p.o += o
}
return r
}
func (p *parser) xatom() string {
return p.xtakefn1(func(c rune, i int) bool {
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
return true
}
return isalphadigit(c) || (c > 0x7f && p.smtputf8)
})
}
func (p *parser) xbodyLength() int64 {
// ../rfc/6376:1265
return p.xnumber(76)
}
func (p *parser) xnumber(maxdigits int) int64 {
o := -1
for i, c := range p.s[p.o:] {
if c >= '0' && c <= '9' {
o = i
} else {
break
}
}
if o == -1 {
p.xerrorf("expected digits")
}
if o+1 > maxdigits {
p.xerrorf("too many digits")
}
v, err := strconv.ParseInt(p.xtaken(o+1), 10, 64)
if err != nil {
p.xerrorf("parsing digits: %s", err)
}
return v
}
func (p *parser) xqueryMethods() []string {
// ../rfc/6376:1285
l := []string{p.xqtagmethod()}
for p.peekfws(":") {
p.fws()
p.xtake(":")
l = append(l, p.xqtagmethod())
}
return l
}
func (p *parser) xqtagmethod() string {
// ../rfc/6376:1295 ../rfc/6376-eid4810
s := p.xhyphenatedWord()
// ABNF production "x-sig-q-tag-args" should probably just have been
// "hyphenated-word". As qp-hdr-value, it will consume ":". A similar problem does
// not occur for "z" because it is also "|"-delimited. We work around the potential
// issue by parsing "dns/txt" explicitly.
rem := p.s[p.o:]
if strings.EqualFold(s, "dns") && len(rem) >= len("/txt") && strings.EqualFold(rem[:len("/txt")], "/txt") {
s += p.xtaken(4)
} else if p.take("/") {
s += "/" + p.xqp(true, true)
}
return s
}
func isalpha(c rune) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
func isdigit(c rune) bool {
return c >= '0' && c <= '9'
}
func isalphadigit(c rune) bool {
return isalpha(c) || isdigit(c)
}
// ../rfc/6376:469
func (p *parser) xhyphenatedWord() string {
return p.xtakefn1(func(c rune, i int) bool {
return isalpha(c) || i > 0 && isdigit(c) || i > 0 && c == '-' && p.o+i+1 < len(p.s) && isalphadigit(rune(p.s[p.o+i+1]))
})
}
// ../rfc/6376:474
func (p *parser) xqphdrvalue() string {
return p.xqp(true, false)
}
func (p *parser) xqpSection() string {
return p.xqp(false, false)
}
// dkim-quoted-printable (pipeEncoded true) or qp-section.
//
// It is described in terms of (lots of) modifications to MIME quoted-printable,
// but it may be simpler to just ignore that reference.
func (p *parser) xqp(pipeEncoded, colonEncoded bool) string {
// ../rfc/6376:494 ../rfc/2045:1260
hex := func(c byte) rune {
if c >= '0' && c <= '9' {
return rune(c - '0')
}
return rune(10 + c - 'A')
}
s := ""
for !p.empty() {
p.fws()
if pipeEncoded && p.hasPrefix("|") {
break
}
if colonEncoded && p.hasPrefix(":") {
break
}
if p.hasPrefix("=") {
p.xtake("=")
// note: \r\n before the full hex-octet has been encountered in the wild. Could be
// a sender just wrapping their headers after escaping, or not escaping an "=". We
// currently don't compensate for it.
h := p.xtakefn(func(c rune, i int) bool {
return i < 2 && (c >= '0' && c <= '9' || c >= 'A' && c <= 'Z')
})
if len(h) != 2 {
p.xerrorf("expected qp-hdr-value")
}
c := (hex(h[0]) << 4) | hex(h[1])
s += string(c)
continue
}
x := p.xtakefn(func(c rune, i int) bool {
return c > ' ' && c < 0x7f && c != ';' && c != '=' && !(pipeEncoded && c == '|')
})
if x == "" {
break
}
s += x
}
return s
}
func (p *parser) xselector() dns.Domain {
return p.xdomain()
}
func (p *parser) xtimestamp() int64 {
// ../rfc/6376:1325 ../rfc/6376:1358
return p.xnumber(12)
}
func (p *parser) xcopiedHeaderFields() []string {
// ../rfc/6376:1384
l := []string{p.xztagcopy()}
for p.hasPrefix("|") {
p.xtake("|")
p.fws()
l = append(l, p.xztagcopy())
}
return l
}
func (p *parser) xztagcopy() string {
// ../rfc/6376:1386
f := p.xhdrName()
p.fws()
p.xtake(":")
v := p.xqphdrvalue()
return f + ":" + v
}

49
dkim/policy.go Normal file
View File

@ -0,0 +1,49 @@
package dkim
import (
"fmt"
"strings"
)
// DefaultPolicy is the default DKIM policy.
//
// Signatures with a length restriction are rejected because it is hard to decide
// how many signed bytes should be required (none? at least half? all except
// max N bytes?). Also, it isn't likely email applications (MUAs) will be
// displaying the signed vs unsigned (partial) content differently, mostly
// because the encoded data is signed. E.g. half a base64 image could be
// signed, and the rest unsigned.
//
// Signatures without Subject field are rejected. The From header field is
// always required and does not need to be checked in the policy.
// Other signatures are accepted.
func DefaultPolicy(sig *Sig) error {
// ../rfc/6376:2088
// ../rfc/6376:2307
// ../rfc/6376:2706
// ../rfc/6376:1558
if sig.Length >= 0 {
return fmt.Errorf("l= for length not acceptable")
}
// ../rfc/6376:2139
// We require at least the following headers: From, Subject.
// You would expect To, Cc and Message-ID to also always be present.
// Microsoft appears to leave out To.
// Yahoo appears to leave out Message-ID.
// Multiple leave out Cc and other address headers.
// At least one newsletter did not sign Date.
var subject bool
for _, h := range sig.SignedHeaders {
subject = subject || strings.EqualFold(h, "subject")
}
var missing []string
if !subject {
missing = append(missing, "subject")
}
if len(missing) > 0 {
return fmt.Errorf("required header fields missing from signature: %s", strings.Join(missing, ", "))
}
return nil
}

353
dkim/sig.go Normal file
View File

@ -0,0 +1,353 @@
package dkim
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/smtp"
)
// Sig is a DKIM-Signature header.
//
// String values must be compared case insensitively.
type Sig struct {
// Required fields.
Version int // Version, 1. Field "v". Always the first field.
AlgorithmSign string // "rsa" or "ed25519". Field "a".
AlgorithmHash string // "sha256" or the deprecated "sha1" (deprecated). Field "a".
Signature []byte // Field "b".
BodyHash []byte // Field "bh".
Domain dns.Domain // Field "d".
SignedHeaders []string // Duplicates are meaningful. Field "h".
Selector dns.Domain // Selector, for looking DNS TXT record at <s>._domainkey.<domain>. Field "s".
// Optional fields.
// Canonicalization is the transformation of header and/or body before hashing. The
// value is in original case, but must be compared case-insensitively. Normally two
// slash-separated values: header canonicalization and body canonicalization. But
// the "simple" means "simple/simple" and "relaxed" means "relaxed/simple". Field
// "c".
Canonicalization string
Length int64 // Body length to verify, default -1 for whole body. Field "l".
Identity *Identity // AUID (agent/user id). If nil and an identity is needed, should be treated as an Identity without localpart and Domain from d= field. Field "i".
QueryMethods []string // For public key, currently known value is "dns/txt" (should be compared case-insensitively). If empty, dns/txt must be assumed. Field "q".
SignTime int64 // Unix epoch. -1 if unset. Field "t".
ExpireTime int64 // Unix epoch. -1 if unset. Field "x".
CopiedHeaders []string // Copied header fields. Field "z".
}
// Identity is used for the optional i= field in a DKIM-Signature header. It uses
// the syntax of an email address, but does not necessarily represent one.
type Identity struct {
Localpart *smtp.Localpart // Optional.
Domain dns.Domain
}
// String returns a value for use in the i= DKIM-Signature field.
func (i Identity) String() string {
s := "@" + i.Domain.ASCII
// We need localpart as pointer to indicate it is missing because localparts can be
// "" which we store (decoded) as empty string and we need to differentiate.
if i.Localpart != nil {
s = i.Localpart.String() + s
}
return s
}
func newSigWithDefaults() *Sig {
return &Sig{
Canonicalization: "simple/simple",
Length: -1,
SignTime: -1,
ExpireTime: -1,
}
}
// Algorithm returns an algorithm string for use in the "a" field. E.g.
// "ed25519-sha256".
func (s Sig) Algorithm() string {
return s.AlgorithmSign + "-" + s.AlgorithmHash
}
// Header returns the DKIM-Signature header in string form, to be prepended to a
// message, including DKIM-Signature field name and trailing \r\n.
func (s *Sig) Header() (string, error) {
// ../rfc/6376:1021
// todo: make a higher-level writer that accepts pairs, and only folds to next line when needed.
w := &message.HeaderWriter{}
w.Addf("", "DKIM-Signature: v=%d;", s.Version)
// Domain names must always be in ASCII. ../rfc/6376:1115 ../rfc/6376:1187 ../rfc/6376:1303
w.Addf(" ", "d=%s;", s.Domain.ASCII)
w.Addf(" ", "s=%s;", s.Selector.ASCII)
if s.Identity != nil {
w.Addf(" ", "i=%s;", s.Identity.String()) // todo: Is utf-8 ok here?
}
w.Addf(" ", "a=%s;", s.Algorithm())
if s.Canonicalization != "" && !strings.EqualFold(s.Canonicalization, "simple") && !strings.EqualFold(s.Canonicalization, "simple/simple") {
w.Addf(" ", "c=%s;", s.Canonicalization)
}
if s.Length >= 0 {
w.Addf(" ", "l=%d;", s.Length)
}
if len(s.QueryMethods) > 0 && !(len(s.QueryMethods) == 1 && strings.EqualFold(s.QueryMethods[0], "dns/txt")) {
w.Addf(" ", "q=%s;", strings.Join(s.QueryMethods, ":"))
}
if s.SignTime >= 0 {
w.Addf(" ", "t=%d;", s.SignTime)
}
if s.ExpireTime >= 0 {
w.Addf(" ", "x=%d;", s.ExpireTime)
}
if len(s.SignedHeaders) > 0 {
for i, v := range s.SignedHeaders {
sep := ""
if i == 0 {
v = "h=" + v
sep = " "
}
if i < len(s.SignedHeaders)-1 {
v += ":"
} else if i == len(s.SignedHeaders)-1 {
v += ";"
}
w.Addf(sep, v)
}
}
if len(s.CopiedHeaders) > 0 {
// todo: wrap long headers? we can at least add FWS before the :
for i, v := range s.CopiedHeaders {
t := strings.SplitN(v, ":", 2)
if len(t) == 2 {
v = t[0] + ":" + packQpHdrValue(t[1])
} else {
return "", fmt.Errorf("invalid header in copied headers (z=): %q", v)
}
sep := ""
if i == 0 {
v = "z=" + v
sep = " "
}
if i < len(s.CopiedHeaders)-1 {
v += "|"
} else if i == len(s.CopiedHeaders)-1 {
v += ";"
}
w.Addf(sep, v)
}
}
w.Addf(" ", "bh=%s;", base64.StdEncoding.EncodeToString(s.BodyHash))
w.Addf(" ", "b=")
if len(s.Signature) > 0 {
w.AddWrap([]byte(base64.StdEncoding.EncodeToString(s.Signature)))
}
w.Add("\r\n")
return w.String(), nil
}
// Like quoted printable, but with "|" encoded as well.
// We also encode ":" because it is used as separator in DKIM headers which can
// cause trouble for "q", even though it is listed in dkim-safe-char,
// ../rfc/6376:497.
func packQpHdrValue(s string) string {
// ../rfc/6376:474
const hex = "0123456789ABCDEF"
var r string
for _, b := range []byte(s) {
if b > ' ' && b < 0x7f && b != ';' && b != '=' && b != '|' && b != ':' {
r += string(b)
} else {
r += "=" + string(hex[b>>4]) + string(hex[(b>>0)&0xf])
}
}
return r
}
var (
errSigHeader = errors.New("not DKIM-Signature header")
errSigDuplicateTag = errors.New("duplicate tag")
errSigMissingCRLF = errors.New("missing crlf at end")
errSigExpired = errors.New("signature timestamp (t=) must be before signature expiration (x=)")
errSigIdentityDomain = errors.New("identity domain (i=) not under domain (d=)")
errSigMissingTag = errors.New("missing required tag")
errSigUnknownVersion = errors.New("unknown version")
errSigBodyHash = errors.New("bad body hash size given algorithm")
)
// parseSignatures returns the parsed form of a DKIM-Signature header.
//
// buf must end in crlf, as it should have occurred in the mail message.
//
// The dkim signature with signature left empty ("b=") and without trailing
// crlf is returned, for use in verification.
func parseSignature(buf []byte, smtputf8 bool) (sig *Sig, verifySig []byte, err error) {
defer func() {
if x := recover(); x == nil {
return
} else if xerr, ok := x.(error); ok {
sig = nil
verifySig = nil
err = xerr
} else {
panic(x)
}
}()
xerrorf := func(format string, args ...any) {
panic(fmt.Errorf(format, args...))
}
if !bytes.HasSuffix(buf, []byte("\r\n")) {
xerrorf("%w", errSigMissingCRLF)
}
buf = buf[:len(buf)-2]
ds := newSigWithDefaults()
seen := map[string]struct{}{}
p := parser{s: string(buf), smtputf8: smtputf8}
name := p.xhdrName()
if !strings.EqualFold(name, "DKIM-Signature") {
xerrorf("%w", errSigHeader)
}
p.wsp()
p.xtake(":")
p.wsp()
// ../rfc/6376:655
// ../rfc/6376:656 ../rfc/6376-eid5070
// ../rfc/6376:658 ../rfc/6376-eid5070
for {
p.fws()
k := p.xtagName()
p.fws()
p.xtake("=")
// Special case for "b", see below.
if k != "b" {
p.fws()
}
// Keys are case-sensitive: ../rfc/6376:679
if _, ok := seen[k]; ok {
// Duplicates not allowed: ../rfc/6376:683
xerrorf("%w: %q", errSigDuplicateTag, k)
break
}
seen[k] = struct{}{}
// ../rfc/6376:1021
switch k {
case "v":
// ../rfc/6376:1025
ds.Version = int(p.xnumber(10))
if ds.Version != 1 {
xerrorf("%w: version %d", errSigUnknownVersion, ds.Version)
}
case "a":
// ../rfc/6376:1038
ds.AlgorithmSign, ds.AlgorithmHash = p.xalgorithm()
case "b":
// ../rfc/6376:1054
// To calculate the hash, we have to feed the DKIM-Signature header to the hash
// function, but with the value for "b=" (the signature) left out. The parser
// tracks all data that is read, except when drop is true.
// ../rfc/6376:997
// Surrounding whitespace must be cleared as well. ../rfc/6376:1659
// Note: The RFC says "surrounding" whitespace, but whitespace is only allowed
// before the value as part of the ABNF production for "b". Presumably the
// intention is to ignore the trailing "[FWS]" for the tag-spec production,
// ../rfc/6376:656
// Another indication is the term "value portion", ../rfc/6376:1667. It appears to
// mean everything after the "b=" part, instead of the actual value (either encoded
// or decoded).
p.drop = true
p.fws()
ds.Signature = p.xbase64()
p.fws()
p.drop = false
case "bh":
// ../rfc/6376:1076
ds.BodyHash = p.xbase64()
case "c":
// ../rfc/6376:1088
ds.Canonicalization = p.xcanonical()
// ../rfc/6376:810
case "d":
// ../rfc/6376:1105
ds.Domain = p.xdomain()
case "h":
// ../rfc/6376:1134
ds.SignedHeaders = p.xsignedHeaderFields()
case "i":
// ../rfc/6376:1171
id := p.xauid()
ds.Identity = &id
case "l":
// ../rfc/6376:1244
ds.Length = p.xbodyLength()
case "q":
// ../rfc/6376:1268
ds.QueryMethods = p.xqueryMethods()
case "s":
// ../rfc/6376:1300
ds.Selector = p.xselector()
case "t":
// ../rfc/6376:1310
ds.SignTime = p.xtimestamp()
case "x":
// ../rfc/6376:1327
ds.ExpireTime = p.xtimestamp()
case "z":
// ../rfc/6376:1361
ds.CopiedHeaders = p.xcopiedHeaderFields()
default:
// We must ignore unknown fields. ../rfc/6376:692 ../rfc/6376:1022
p.xchar() // ../rfc/6376-eid5070
for !p.empty() && !p.hasPrefix(";") {
p.xchar()
}
}
p.fws()
if p.empty() {
break
}
p.xtake(";")
if p.empty() {
break
}
}
// ../rfc/6376:2532
required := []string{"v", "a", "b", "bh", "d", "h", "s"}
for _, req := range required {
if _, ok := seen[req]; !ok {
xerrorf("%w: %q", errSigMissingTag, req)
}
}
if strings.EqualFold(ds.AlgorithmHash, "sha1") && len(ds.BodyHash) != 20 {
xerrorf("%w: got %d bytes, must be 20 for sha1", errSigBodyHash, len(ds.BodyHash))
} else if strings.EqualFold(ds.AlgorithmHash, "sha256") && len(ds.BodyHash) != 32 {
xerrorf("%w: got %d bytes, must be 32 for sha256", errSigBodyHash, len(ds.BodyHash))
}
// ../rfc/6376:1337
if ds.SignTime >= 0 && ds.ExpireTime >= 0 && ds.SignTime >= ds.ExpireTime {
xerrorf("%w", errSigExpired)
}
// Default identity is "@" plus domain. We don't set this value because we want to
// keep the distinction between absent value.
// ../rfc/6376:1172 ../rfc/6376:2537 ../rfc/6376:2541
if ds.Identity != nil && ds.Identity.Domain.ASCII != ds.Domain.ASCII && !strings.HasSuffix(ds.Identity.Domain.ASCII, "."+ds.Domain.ASCII) {
xerrorf("%w: identity domain %q not under domain %q", errSigIdentityDomain, ds.Identity.Domain.ASCII, ds.Domain.ASCII)
}
return ds, []byte(p.tracked), nil
}

180
dkim/sig_test.go Normal file
View File

@ -0,0 +1,180 @@
package dkim
import (
"encoding/base64"
"errors"
"reflect"
"strings"
"testing"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
)
func TestSig(t *testing.T) {
test := func(s string, smtputf8 bool, expSig *Sig, expErr error) {
t.Helper()
isParseErr := func(err error) bool {
_, ok := err.(parseErr)
return ok
}
sig, _, err := parseSignature([]byte(s), smtputf8)
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) && !(isParseErr(err) && isParseErr(expErr)) {
t.Fatalf("got err %v, expected %v", err, expErr)
}
if !reflect.DeepEqual(sig, expSig) {
t.Fatalf("got sig %#v, expected %#v", sig, expSig)
}
if sig == nil {
return
}
h, err := sig.Header()
if err != nil {
t.Fatalf("making signature header: %v", err)
}
nsig, _, err := parseSignature([]byte(h), smtputf8)
if err != nil {
t.Fatalf("parse signature again: %v", err)
}
if !reflect.DeepEqual(nsig, sig) {
t.Fatalf("parsed signature again, got %#v, expected %#v", nsig, sig)
}
}
xbase64 := func(s string) []byte {
t.Helper()
buf, err := base64.StdEncoding.DecodeString(s)
if err != nil {
t.Fatalf("parsing base64: %v", err)
}
return buf
}
xdomain := func(s string) dns.Domain {
t.Helper()
d, err := dns.ParseDomain(s)
if err != nil {
t.Fatalf("parsing domain: %v", err)
}
return d
}
var empty smtp.Localpart
sig1 := &Sig{
Version: 1,
AlgorithmSign: "ed25519",
AlgorithmHash: "sha256",
Signature: xbase64("dGVzdAo="),
BodyHash: xbase64("LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q="),
Domain: xdomain("mox.example"),
SignedHeaders: []string{"from", "to", "cc", "bcc", "date", "subject", "message-id"},
Selector: xdomain("test"),
Canonicalization: "simple/relaxed",
Length: 10,
Identity: &Identity{&empty, xdomain("sub.mox.example")},
QueryMethods: []string{"dns/txt", "other"},
SignTime: 10,
ExpireTime: 100,
CopiedHeaders: []string{"From:<mjl@mox.example>", "Subject:test | with pipe"},
}
test("dkim-signature: v = 1 ; a=ed25519-sha256; s=test; d=mox.example; h=from:to:cc:bcc:date:subject:message-id; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q= ; c=simple/relaxed; l=10; i=\"\"@sub.mox.example; q= dns/txt:other; t=10; x=100; z=From:<mjl@mox.example>|Subject:test=20=7C=20with=20pipe; unknown = must be ignored \r\n", true, sig1, nil)
ulp := smtp.Localpart("møx")
sig2 := &Sig{
Version: 1,
AlgorithmSign: "ed25519",
AlgorithmHash: "sha256",
Signature: xbase64("dGVzdAo="),
BodyHash: xbase64("LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q="),
Domain: xdomain("xn--mx-lka.example"), // møx.example
SignedHeaders: []string{"from"},
Selector: xdomain("xn--tst-bma"), // tést
Identity: &Identity{&ulp, xdomain("xn--tst-bma.xn--mx-lka.example")}, // tést.møx.example
Canonicalization: "simple/simple",
Length: -1,
SignTime: -1,
ExpireTime: -1,
}
test("dkim-signature: v = 1 ; a=ed25519-sha256; s=xn--tst-bma; d=xn--mx-lka.example; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q= ; i=møx@xn--tst-bma.xn--mx-lka.example;\r\n", true, sig2, nil)
test("dkim-signature: v = 1 ; a=ed25519-sha256; s=xn--tst-bma; d=xn--mx-lka.example; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q= ; i=møx@xn--tst-bma.xn--mx-lka.example;\r\n", false, nil, parseErr("")) // No UTF-8 allowed.
multiatom := smtp.Localpart("a.b.c")
sig3 := &Sig{
Version: 1,
AlgorithmSign: "ed25519",
AlgorithmHash: "sha256",
Signature: xbase64("dGVzdAo="),
BodyHash: xbase64("LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q="),
Domain: xdomain("mox.example"),
SignedHeaders: []string{"from"},
Selector: xdomain("test"),
Identity: &Identity{&multiatom, xdomain("mox.example")},
Canonicalization: "simple/simple",
Length: -1,
SignTime: -1,
ExpireTime: -1,
}
test("dkim-signature: v = 1 ; a=ed25519-sha256; s=test; d=mox.example; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q= ; i=a.b.c@mox.example\r\n", true, sig3, nil)
quotedlp := smtp.Localpart(`test "\test`)
sig4 := &Sig{
Version: 1,
AlgorithmSign: "ed25519",
AlgorithmHash: "sha256",
Signature: xbase64("dGVzdAo="),
BodyHash: xbase64("LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q="),
Domain: xdomain("mox.example"),
SignedHeaders: []string{"from"},
Selector: xdomain("test"),
Identity: &Identity{&quotedlp, xdomain("mox.example")},
Canonicalization: "simple/simple",
Length: -1,
SignTime: -1,
ExpireTime: -1,
}
test("dkim-signature: v = 1 ; a=ed25519-sha256; s=test; d=mox.example; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q= ; i=\"test \\\"\\\\test\"@mox.example\r\n", true, sig4, nil)
test("", true, nil, errSigMissingCRLF)
test("other: ...\r\n", true, nil, errSigHeader)
test("dkim-signature: v=2\r\n", true, nil, errSigUnknownVersion)
test("dkim-signature: v=1\r\n", true, nil, errSigMissingTag)
test("dkim-signature: v=1;v=1\r\n", true, nil, errSigDuplicateTag)
test("dkim-signature: v=1; d=mox.example; i=@unrelated.example; s=test; a=ed25519-sha256; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q=\r\n", true, nil, errSigIdentityDomain)
test("dkim-signature: v=1; t=10; x=9; d=mox.example; s=test; a=ed25519-sha256; h=from; b=dGVzdAo=; bh=LjkN2rUhrS3zKXfH2vNgUzz5ERRJkgP9CURXBX0JP0Q=\r\n", true, nil, errSigExpired)
test("dkim-signature: v=1; d=møx.example\r\n", true, nil, parseErr("")) // Unicode domain not allowed.
test("dkim-signature: v=1; s=tést\r\n", true, nil, parseErr("")) // Unicode selector not allowed.
test("dkim-signature: v=1; ;\r\n", true, nil, parseErr("")) // Empty tag not allowed.
test("dkim-signature: v=1; \r\n", true, nil, parseErr("")) // Cannot have whitespace after last colon.
test("dkim-signature: v=1; d=mox.example; s=test; a=ed25519-sha256; h=from; b=dGVzdAo=; bh=dGVzdAo=\r\n", true, nil, errSigBodyHash)
test("dkim-signature: v=1; d=mox.example; s=test; a=rsa-sha1; h=from; b=dGVzdAo=; bh=dGVzdAo=\r\n", true, nil, errSigBodyHash)
}
func TestCopiedHeadersSig(t *testing.T) {
// ../rfc/6376:1391
sigHeader := strings.ReplaceAll(`DKIM-Signature: v=1; a=rsa-sha256; d=example.net; s=brisbane;
c=simple; q=dns/txt; i=@eng.example.net;
t=1117574938; x=1118006938;
h=from:to:subject:date;
z=From:foo@eng.example.net|To:joe@example.com|
Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR
`, "\n", "\r\n")
sig, _, err := parseSignature([]byte(sigHeader), false)
if err != nil {
t.Fatalf("parsing dkim signature with copied headers: %v", err)
}
exp := []string{
"From:foo@eng.example.net",
"To:joe@example.com",
"Subject:demo run",
"Date:July 5, 2005 3:44:08 PM -0700",
}
if !reflect.DeepEqual(sig.CopiedHeaders, exp) {
t.Fatalf("copied headers, got %v, expected %v", sig.CopiedHeaders, exp)
}
}

278
dkim/txt.go Normal file
View File

@ -0,0 +1,278 @@
package dkim
import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"strings"
)
// Record is a DKIM DNS record, served on <selector>._domainkey.<domain> for a
// given selector and domain (s= and d= in the DKIM-Signature).
//
// The record is a semicolon-separated list of "="-separated field value pairs.
// Strings should be compared case-insensitively, e.g. k=ed25519 is equivalent to k=ED25519.
//
// Example:
//
// v=DKIM1;h=sha256;k=ed25519;p=ln5zd/JEX4Jy60WAhUOv33IYm2YZMyTQAdr9stML504=
type Record struct {
Version string // Version, fixed "DKIM1" (case sensitive). Field "v".
Hashes []string // Acceptable hash algorithms, e.g. "sha1", "sha256". Optional, defaults to all algorithms. Field "h".
Key string // Key type, "rsa" or "ed25519". Optional, default "rsa". Field "k".
Notes string // Debug notes. Field "n".
Pubkey []byte // Public key, as base64 in record. If empty, the key has been revoked. Field "p".
Services []string // Service types. Optional, default "*" for all services. Other values: "email". Field "s".
Flags []string // Flags, colon-separated. Optional, default is no flags. Other values: "y" for testing DKIM, "s" for "i=" must have same domain as "d" in signatures. Field "t".
PublicKey any `json:"-"` // Parsed form of public key, an *rsa.PublicKey or ed25519.PublicKey.
}
// ../rfc/6376:1438
// ServiceAllowed returns whether service s is allowed by this key.
//
// The optional field "s" can specify purposes for which the key can be used. If
// value was specified, both "*" and "email" are enough for use with DKIM.
func (r *Record) ServiceAllowed(s string) bool {
if len(r.Services) == 0 {
return true
}
for _, ss := range r.Services {
if ss == "*" || strings.EqualFold(s, ss) {
return true
}
}
return false
}
// Record returns a DNS TXT record that should be served at
// <selector>._domainkey.<domain>.
//
// Only values that are not the default values are included.
func (r *Record) Record() (string, error) {
var l []string
add := func(s string) {
l = append(l, s)
}
if r.Version != "DKIM1" {
return "", fmt.Errorf("bad version, must be \"DKIM1\"")
}
add("v=DKIM1")
if len(r.Hashes) > 0 {
add("h=" + strings.Join(r.Hashes, ":"))
}
if r.Key != "" && !strings.EqualFold(r.Key, "rsa") {
add("k=" + r.Key)
}
if r.Notes != "" {
add("n=" + qpSection(r.Notes))
}
if len(r.Services) > 0 && (len(r.Services) != 1 || r.Services[0] != "*") {
add("s=" + strings.Join(r.Services, ":"))
}
if len(r.Flags) > 0 {
add("t=" + strings.Join(r.Flags, ":"))
}
// A missing public key is valid, it means the key has been revoked. ../rfc/6376:1501
pk := r.Pubkey
if len(pk) == 0 && r.PublicKey != nil {
switch k := r.PublicKey.(type) {
case *rsa.PublicKey:
var err error
pk, err = x509.MarshalPKIXPublicKey(k)
if err != nil {
return "", fmt.Errorf("marshal rsa public key: %v", err)
}
case ed25519.PublicKey:
pk = []byte(k)
default:
return "", fmt.Errorf("unknown public key type %T", r.PublicKey)
}
}
add("p=" + base64.StdEncoding.EncodeToString(pk))
return strings.Join(l, ";"), nil
}
func qpSection(s string) string {
const hex = "0123456789ABCDEF"
// ../rfc/2045:1260
var r string
for i, b := range []byte(s) {
if i > 0 && (b == ' ' || b == '\t') || b > ' ' && b < 0x7f && b != '=' {
r += string(rune(b))
} else {
r += "=" + string(hex[b>>4]) + string(hex[(b>>0)&0xf])
}
}
return r
}
var (
errRecordDuplicateTag = errors.New("duplicate tag")
errRecordMissingField = errors.New("missing field")
errRecordBadPublicKey = errors.New("bad public key")
errRecordUnknownAlgorithm = errors.New("unknown algorithm")
errRecordVersionFirst = errors.New("first field must be version")
)
// ParseRecord parses a DKIM DNS TXT record.
//
// If the record is a dkim record, but an error occurred, isdkim will be true and
// err will be the error. Such errors must be treated differently from parse errors
// where the record does not appear to be DKIM, which can happen with misconfigured
// DNS (e.g. wildcard records).
func ParseRecord(s string) (record *Record, isdkim bool, err error) {
defer func() {
x := recover()
if x == nil {
return
}
if xerr, ok := x.(error); ok {
record = nil
err = xerr
return
}
panic(x)
}()
xerrorf := func(format string, args ...any) {
panic(fmt.Errorf(format, args...))
}
record = &Record{
Version: "DKIM1",
Key: "rsa",
Services: []string{"*"},
}
p := parser{s: s, drop: true}
seen := map[string]struct{}{}
// ../rfc/6376:655
// ../rfc/6376:656 ../rfc/6376-eid5070
// ../rfc/6376:658 ../rfc/6376-eid5070
// ../rfc/6376:1438
for {
p.fws()
k := p.xtagName()
p.fws()
p.xtake("=")
p.fws()
// Keys are case-sensitive: ../rfc/6376:679
if _, ok := seen[k]; ok {
// Duplicates not allowed: ../rfc/6376:683
xerrorf("%w: %q", errRecordDuplicateTag, k)
break
}
seen[k] = struct{}{}
// Version must be the first.
switch k {
case "v":
// ../rfc/6376:1443
v := p.xtake("DKIM1")
// Version being set is a signal this appears to be a valid record. We must not
// treat e.g. DKIM1.1 as valid, so we explicitly check there is no more data before
// we decide this record is DKIM.
p.fws()
if !p.empty() {
p.xtake(";")
}
record.Version = v
if len(seen) != 1 {
// If version is present, it must be the first.
xerrorf("%w", errRecordVersionFirst)
}
isdkim = true
if p.empty() {
break
}
continue
case "h":
// ../rfc/6376:1463
record.Hashes = []string{p.xhyphenatedWord()}
for p.peekfws(":") {
p.fws()
p.xtake(":")
p.fws()
record.Hashes = append(record.Hashes, p.xhyphenatedWord())
}
case "k":
// ../rfc/6376:1478
record.Key = p.xhyphenatedWord()
case "n":
// ../rfc/6376:1491
record.Notes = p.xqpSection()
case "p":
// ../rfc/6376:1501
record.Pubkey = p.xbase64()
case "s":
// ../rfc/6376:1533
record.Services = []string{p.xhyphenatedWord()}
for p.peekfws(":") {
p.fws()
p.xtake(":")
p.fws()
record.Services = append(record.Services, p.xhyphenatedWord())
}
case "t":
// ../rfc/6376:1554
record.Flags = []string{p.xhyphenatedWord()}
for p.peekfws(":") {
p.fws()
p.xtake(":")
p.fws()
record.Flags = append(record.Flags, p.xhyphenatedWord())
}
default:
// We must ignore unknown fields. ../rfc/6376:692 ../rfc/6376:1439
for !p.empty() && !p.hasPrefix(";") {
p.xchar()
}
}
isdkim = true
p.fws()
if p.empty() {
break
}
p.xtake(";")
if p.empty() {
break
}
}
if _, ok := seen["p"]; !ok {
xerrorf("%w: public key", errRecordMissingField)
}
switch strings.ToLower(record.Key) {
case "", "rsa":
if len(record.Pubkey) == 0 {
// Revoked key, nothing to do.
} else if pk, err := x509.ParsePKIXPublicKey(record.Pubkey); err != nil {
xerrorf("%w: %s", errRecordBadPublicKey, err)
} else if _, ok := pk.(*rsa.PublicKey); !ok {
xerrorf("%w: got %T, need an RSA key", errRecordBadPublicKey, record.PublicKey)
} else {
record.PublicKey = pk
}
case "ed25519":
if len(record.Pubkey) == 0 {
// Revoked key, nothing to do.
} else if len(record.Pubkey) != ed25519.PublicKeySize {
xerrorf("%w: got %d bytes, need %d", errRecordBadPublicKey, len(record.Pubkey), ed25519.PublicKeySize)
} else {
record.PublicKey = ed25519.PublicKey(record.Pubkey)
}
default:
xerrorf("%w: %q", errRecordUnknownAlgorithm, record.Key)
}
return record, true, nil
}

133
dkim/txt_test.go Normal file
View File

@ -0,0 +1,133 @@
package dkim
import (
"crypto/x509"
"encoding/base64"
"errors"
"reflect"
"testing"
)
func TestParseRecord(t *testing.T) {
test := func(txt string, expRec *Record, expIsDKIM bool, expErr error) {
t.Helper()
isParseErr := func(err error) bool {
_, ok := err.(parseErr)
return ok
}
r, isdkim, err := ParseRecord(txt)
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) && !(isParseErr(err) && isParseErr(expErr)) {
t.Fatalf("parsing record: got error %v %#v, expected %#v, txt %q", err, err, expErr, txt)
}
if isdkim != expIsDKIM {
t.Fatalf("got isdkim %v, expected %v", isdkim, expIsDKIM)
}
if r != nil && expRec != nil {
expRec.PublicKey = r.PublicKey
}
if !reflect.DeepEqual(r, expRec) {
t.Fatalf("got record %#v, expected %#v, for txt %q", r, expRec, txt)
}
if r != nil {
pk := r.Pubkey
for i := 0; i < 2; i++ {
ntxt, err := r.Record()
if err != nil {
t.Fatalf("making record: %v", err)
}
nr, _, _ := ParseRecord(ntxt)
r.Pubkey = pk
if !reflect.DeepEqual(r, nr) {
t.Fatalf("after packing and parsing, got %#v, expected %#v", nr, r)
}
// Generate again, now based on parsed public key.
pk = r.Pubkey
r.Pubkey = nil
}
}
}
xbase64 := func(s string) []byte {
t.Helper()
buf, err := base64.StdEncoding.DecodeString(s)
if err != nil {
t.Fatalf("parsing base64: %v", err)
}
return buf
}
test("", nil, false, parseErr(""))
test("v=DKIM1", nil, true, errRecordMissingField) // Missing p=.
test("p=; v=DKIM1", nil, true, errRecordVersionFirst)
test("v=DKIM1; p=; ", nil, true, parseErr("")) // Whitespace after last ; is not allowed.
test("v=dkim1; p=; ", nil, false, parseErr("")) // dkim1-value is case-sensitive.
test("v=DKIM1; p=JDcbZ0Hpba5NKXI4UAW3G0IDhhFOxhJTDybZEwe1FeA=", nil, true, errRecordBadPublicKey) // Not an rsa key.
test("v=DKIM1; p=; p=", nil, true, errRecordDuplicateTag) // Duplicate tag.
test("v=DKIM1; k=ed25519; p=HbawiMnQXTCopHTkR0jlKQ==", nil, true, errRecordBadPublicKey) // Short key.
test("v=DKIM1; k=unknown; p=", nil, true, errRecordUnknownAlgorithm)
empty := &Record{
Version: "DKIM1",
Key: "rsa",
Services: []string{"*"},
Pubkey: []uint8{},
}
test("V=DKIM2; p=;", empty, true, nil) // Tag names are case-sensitive.
record := &Record{
Version: "DKIM1",
Hashes: []string{"sha1", "SHA256", "unknown"},
Key: "ed25519",
Notes: "notes...",
Pubkey: xbase64("JDcbZ0Hpba5NKXI4UAW3G0IDhhFOxhJTDybZEwe1FeA="),
Services: []string{"email", "tlsrpt"},
Flags: []string{"y", "t"},
}
test("v = DKIM1 ; h\t=\tsha1 \t:\t SHA256:unknown\t;k=ed25519; n = notes...; p = JDc bZ0Hpb a5NK\tXI4UAW3G0IDhhFOxhJTDybZEwe1FeA= ;s = email : tlsrpt; t = y\t: t; unknown = bogus;", record, true, nil)
edpkix, err := x509.MarshalPKIXPublicKey(record.PublicKey)
if err != nil {
t.Fatalf("marshal ed25519 public key")
}
recordx := &Record{
Version: "DKIM1",
Key: "rsa",
Pubkey: edpkix,
}
txtx, err := recordx.Record()
if err != nil {
t.Fatalf("making record: %v", err)
}
test(txtx, nil, true, errRecordBadPublicKey)
record2 := &Record{
Version: "DKIM1",
Key: "rsa",
Services: []string{"*"},
Pubkey: xbase64("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy3Z9ffZe8gUTJrdGuKj6IwEembmKYpp0jMa8uhudErcI4gFVUaFiiRWxc4jP/XR9NAEv3XwHm+CVcHu+L/n6VWt6g59U7vHXQicMfKGmEp2VplsgojNy/Y5X9HdVYM0azsI47NcJCDW9UVfeOHdOSgFME4F8dNtUKC4KTB2d1pqj/yixz+V8Sv8xkEyPfSRHcNXIw0LvelqJ1MRfN3hO/3uQSVrPYYk4SyV0b6wfnkQs28fpiIpGQvzlGI5WkrdOQT5k4YHaEvZDLNdwiMeVZOEL7dDoFs2mQsovm+tH0StUAZTnr61NLVFfD5V6Ip1V9zVtspPHvYSuOWwyArFZ9QIDAQAB"),
}
test("v=DKIM1;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy3Z9ffZe8gUTJrdGuKj6IwEembmKYpp0jMa8uhudErcI4gFVUaFiiRWxc4jP/XR9NAEv3XwHm+CVcHu+L/n6VWt6g59U7vHXQicMfKGmEp2VplsgojNy/Y5X9HdVYM0azsI47NcJCDW9UVfeOHdOSgFME4F8dNtUKC4KTB2d1pqj/yixz+V8Sv8xkEyPfSRHcNXIw0LvelqJ1MRfN3hO/3uQSVrPYYk4SyV0b6wfnkQs28fpiIpGQvzlGI5WkrdOQT5k4YHaEvZDLNdwiMeVZOEL7dDoFs2mQsovm+tH0StUAZTnr61NLVFfD5V6Ip1V9zVtspPHvYSuOWwyArFZ9QIDAQAB", record2, true, nil)
}
func TestQPSection(t *testing.T) {
var tests = []struct {
input string
expect string
}{
{"test", "test"},
{"hi=", "hi=3D"},
{"hi there", "hi there"},
{" hi", "=20hi"},
{"t\x7f", "t=7F"},
}
for _, v := range tests {
r := qpSection(v.input)
if r != v.expect {
t.Fatalf("qpSection: input %q, expected %q, got %q", v.input, v.expect, r)
}
}
}