mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +03:00
mox!
This commit is contained in:
849
dkim/dkim.go
Normal file
849
dkim/dkim.go
Normal 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
702
dkim/dkim_test.go
Normal 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
25
dkim/fuzz_test.go
Normal 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
474
dkim/parser.go
Normal 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
49
dkim/policy.go
Normal 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
353
dkim/sig.go
Normal 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
180
dkim/sig_test.go
Normal 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{"edlp, 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
278
dkim/txt.go
Normal 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
133
dkim/txt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user