diff --git a/stevea/.gitignore b/stevea/.gitignore new file mode 100644 index 0000000..4cebfb0 --- /dev/null +++ b/stevea/.gitignore @@ -0,0 +1,34 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ +/cmd/dkim2sign/dkim2sign +/.idea diff --git a/stevea/INTEROP-NOTES.md b/stevea/INTEROP-NOTES.md new file mode 100644 index 0000000..e21d6c2 --- /dev/null +++ b/stevea/INTEROP-NOTES.md @@ -0,0 +1,47 @@ +## 1. Email address angle brackets + +It's not clear to me whether we expect the email addresses stored +in mf= and rt=, and the same addresses passed in via API, to be +surrounded with angle brackets or not. + +## 2. RSA public key type + +We've inherited the [uncertainty about public key format](https://www.rfc-editor.org/errata/eid3017) +for RSA keys from DKIM. That means we need to attempt to parse it +in both formats and use the one that works. + +## 3. Retrieving i= / m= for error messages + +If a Dkim2-Signature or Message-Instance header cannot be parsed then +we need to return an error. That error should contain the i=/m= to +identify which header, but we weren't able to parse it. I've added +fallback parsing to just look for the identifier tag when parsing +fails. + +## 4. Error messages missing + +spec-01 includes a set of standard error messages, but they don't +cover some error cases: + + - Duplicate i=/m= in header + - Header doesn't contain i=/m= + +## 5. Hash comparisons + +Malicious input could cause non-canonical base64 encoded hashes, +so comparing hashes as base64 encoded text isn't safe. I'm +erroring out for non-canonical encoding (and comparing the +decoded binary rather than the text). + +## 6. Identifying s= + +During verification we need to modify the s= tag in the +most recent Dkim2-Signature to blank out the signature. +To do that we need to find the s= tag. + +A simple search for "s=" may fail, as "s=" could appear +in base64 encoded fields. + +Searching for a regex `;\s*s=` looks more reasonable, as +semicolons don't appear in base64. But if s= is the first +tag in the header that'd fail. \ No newline at end of file diff --git a/stevea/LICENSE b/stevea/LICENSE new file mode 100644 index 0000000..7027ef8 --- /dev/null +++ b/stevea/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2025, Turscar Ltd + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/stevea/NOTES.md b/stevea/NOTES.md new file mode 100644 index 0000000..ff565e8 --- /dev/null +++ b/stevea/NOTES.md @@ -0,0 +1,15 @@ +## Error Messages + +We don't have a "duplicate" i=/m= error. Using Syntax Error for now. + +We don't have "these headers are so mangled I can't get i= or m=". + +Having a "in which tag" for syntax error would be nice. + +Added "FAIL: DKIM2-Signature i= validation failed" + +## RSA + +https://www.rfc-editor.org/errata/eid3017 + +Min RSA key length diff --git a/stevea/README.md b/stevea/README.md new file mode 100644 index 0000000..e232649 --- /dev/null +++ b/stevea/README.md @@ -0,0 +1,22 @@ +# dkim2 +DKIM2 library and tools + +## Tests + +Run `go test . -count=1 -v` to run library test and interop tests +(parsing expected results from python and brong). + +Our signed messages are in stevea/testdata/golden/expected. + +## Status + +The library seems to generate and parse messages correctly. + +The CLI tools are not ready for use. + +## Missing + +Validation of MAIL FROM / RCPT TO chains. + +Results of message-instance recipes that return a "we +can't reconstruct this" result. diff --git a/stevea/cf_python_test.go b/stevea/cf_python_test.go new file mode 100644 index 0000000..a6b0126 --- /dev/null +++ b/stevea/cf_python_test.go @@ -0,0 +1,89 @@ +package dkim2 + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestPython_Verify(t *testing.T) { + tests := map[string]struct { + MailFrom string + RcptTo []string + }{ + "simple-ed25519": { + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + "simple-rsa1024": { + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + "simple-rsa2048": {}, + "simple-sel2": {}, + "simple-sel3": {}, + "dsn-ed25519": {}, + "dupheaders-ed25519": {}, + "emptybody-ed25519": {}, + "multiheader-ed25519": {}, + "trailingblank-ed25519": {}, + "multirecipient-ed25519": {}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + f, err := os.Open(filepath.Join("testdata", "interop", "python", "expected", name+".eml")) + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + result := Verify(context.Background(), f, + VerifyOptions{ + IgnoreTimestamp: true, + Resolver: NewTestResolver(), + MailFrom: tc.MailFrom, + RcptTo: tc.RcptTo, + }) + if result.State() != StatePass { + t.Fatalf("Failed to verify message: %v", result.AuthenticationResult()) + } + }) + } +} + +func TestPython_VerifyAll(t *testing.T) { + tests := map[string]struct { + MailFrom string + RcptTo []string + }{ + "multihop-3hop-dup-headers": {}, + "multihop-body-footer": {}, + "multihop-dup-headers": {}, + "multihop-header-add": {}, + "multihop-header-replace": {}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + f, err := os.Open(filepath.Join("testdata", "interop", "python", "expected", name+".eml")) + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + result := VerifyAll(context.Background(), f, + VerifyOptions{ + IgnoreTimestamp: true, + Resolver: NewTestResolver(), + MailFrom: tc.MailFrom, + RcptTo: tc.RcptTo, + }) + if result.State() != StatePass { + t.Fatalf("Failed to verify message: %v", result.AuthenticationResult()) + } + }) + } +} diff --git a/stevea/dkim2signature.go b/stevea/dkim2signature.go new file mode 100644 index 0000000..2bb6d6a --- /dev/null +++ b/stevea/dkim2signature.go @@ -0,0 +1,539 @@ +package dkim2 + +import ( + "bytes" + "cmp" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/mail" + "slices" + "strconv" + "strings" + "unicode" +) + +// Sig is one selector / algorithm name / signature data tuple. +type Sig struct { + Selector string `json:"selector"` + Name string `json:"name"` + Signature []byte `json:"signature"` +} + +func (s Sig) String() string { + var builder strings.Builder + builder.WriteString(s.Selector) + builder.WriteByte(':') + builder.WriteString(s.Name) + builder.WriteByte(':') + encoder := base64.NewEncoder(base64.StdEncoding, &builder) + _, _ = encoder.Write(s.Signature) + _ = encoder.Close() + return builder.String() +} + +// Signature represents the content of a DKIM2-Signature header. +type Signature struct { + Sequence int `json:"i"` + MIRevision int `json:"m"` + Nonce string `json:"n"` + Timestamp int64 `json:"t"` + MailFrom string `json:"mf"` + RcptTo []string `json:"rt"` + Domain string `json:"d"` + Signatures []Sig `json:"s"` + Exploded bool `json:"f-exploded"` + DoNotExplode bool `json:"f-do-not-explode"` + DoNotModify bool `json:"f-do-not-modify"` + Feedback bool `json:"f-feedback"` + ExtraFlags []string `json:"f-extra"` + Original string `json:"original"` +} + +// ParseSignature creates a new Signature from a DKIM2-Signature header. +func ParseSignature(h string) (*Signature, error) { + tags, err := NewTags(hdrDKIM2Signature, h, "i") + if err != nil { + return nil, err + } + ret := Signature{ + Original: h, + } + + seq, ok := tags.Get("i") + if !ok { + return nil, ErrMissingTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "i", + } + } + seqI, err := strconv.ParseInt(seq, 10, 32) + if err != nil { + return nil, ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "i", + TagVal: seq, + Err: err, + } + } + ret.Sequence = int(seqI) + + rev, ok := tags.Get("m") + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Tag: "m", + Header: h, + } + } + revI, err := strconv.ParseInt(rev, 10, 32) + if err != nil { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "m", + TagVal: rev, + Err: err, + }, + } + } + ret.MIRevision = int(revI) + + nonce, ok := tags.Get("n") + if ok { + if len(nonce) > 64 { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "n", + TagVal: nonce, + Err: errors.New("cannot be longer than 64 characters"), + }, + } + } + ret.Nonce = nonce + } + + timestamp, ok := tags.Get("t") + + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Header: h, + Tag: "t", + } + } + ret.Timestamp, err = strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "t", + TagVal: timestamp, + Err: err, + }, + } + } + + mailFrom, ok := tags.Get("mf") + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Header: h, + Tag: "mf", + } + } + mf, err := base64.StdEncoding.Strict().DecodeString(mailFrom) + if err != nil { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "mf", + TagVal: mailFrom, + Err: err, + }, + } + } + if !isPrintString(mf) { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "mf", + TagVal: mailFrom, + Err: errors.New("decoded mf must contain only printable characters"), + }, + } + } + ret.MailFrom = string(mf) + + rcptTo, ok := tags.Get("rt") + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Tag: "rt", + Header: h, + } + } + for _, addr := range strings.Split(rcptTo, ",") { + addr = strings.TrimSpace(addr) + rt, err := base64.StdEncoding.Strict().DecodeString(addr) + if err != nil { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "rt", + TagVal: rcptTo, + Err: err, + }, + } + } + if !isPrintString(rt) { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "rt", + TagVal: rcptTo, + Err: errors.New("decoded rt must contain only printable characters"), + }, + } + } + ret.RcptTo = append(ret.RcptTo, string(rt)) + } + + domain, ok := tags.Get("d") + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Header: h, + Tag: "d", + } + } + ret.Domain = domain + + sel, ok := tags.Get("s") + if !ok { + return nil, ErrSignatureTagMissing{ + Sequence: ret.Sequence, + Header: h, + Tag: "s", + } + } + sels := strings.Split(sel, ",") + for _, s := range sels { + parts := strings.Split(s, ":") + if len(parts) != 3 { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "s", + TagVal: sel, + Err: errors.New("each signature should have three parts"), + }, + } + } + sig, err := base64.StdEncoding.Strict().DecodeString(removeWhitespace(parts[2])) + if err != nil { + return nil, ErrSignatureSyntaxError{ + Sequence: ret.Sequence, + Header: h, + Err: ErrInvalidTag{ + Name: hdrDKIM2Signature, + V: h, + Tag: "s", + TagVal: sel, + Err: err, + }, + } + } + ret.Signatures = append(ret.Signatures, Sig{ + Selector: removeWhitespace(parts[0]), + Name: removeWhitespace(parts[1]), + Signature: sig, + }) + } + f, ok := tags.Get("f") + if ok { + for _, flag := range strings.Split(f, ",") { + flag = strings.TrimSpace(flag) + switch flag { + case "exploded": + ret.Exploded = true + case "donotexplode": + ret.DoNotExplode = true + case "donotmodify": + ret.DoNotModify = true + case "feedback": + ret.Feedback = true + default: + ret.ExtraFlags = append(ret.ExtraFlags, flag) + } + } + } + return &ret, nil +} + +func (s *Signature) Flags() []string { + flags := s.ExtraFlags + if s.DoNotExplode { + flags = append(flags, "donotexplode") + } + if s.DoNotModify { + flags = append(flags, "donotmodify") + } + if s.Feedback { + flags = append(flags, "feedback") + } + if s.Exploded { + flags = append(flags, "exploded") + } + return flags +} + +type indexedHeader struct { + index int + payload []byte +} + +func indexHeader(name string, h string, tag string) (indexedHeader, error) { + tags, err := NewTags(hdrDKIM2Signature, h, "i") + if err != nil { + return indexedHeader{}, ErrMalformedHeader{ + Name: name, + V: h, + Err: err, + } + } + idx, ok := tags.Get(tag) + if !ok { + return indexedHeader{}, ErrMissingTag{ + Name: name, + V: h, + Tag: tag, + } + } + index, err := strconv.ParseInt(idx, 10, 32) + if err != nil { + return indexedHeader{}, ErrInvalidTag{ + Name: name, + V: h, + Tag: tag, + TagVal: idx, + Err: err, + } + } + payload := make([]byte, 0, len(h)+len(name)+3) + payload = append(payload, []byte(name)...) + payload = append(payload, byte(':')) + payload = append(payload, whitespaceRe.ReplaceAll([]byte(h), []byte{})...) + payload = append(payload, []byte("\r\n")...) + return indexedHeader{index: int(index), payload: payload}, nil +} + +// ConcatHeadersForSigning generates the byte slice we need to +// sign for the header signature. +func (d *Signature) ConcatHeadersForSigning(headers mail.Header) ([]byte, error) { + var payload []byte + /* + * Place the header fields in order. First come the Message-Instance + header fields in ascending instance (m=) order. Second are the + DKIM2-Signature header fields in ascending sequence (i=) order. + Last of all is an incomplete DKIM2-Signature header field (the + one that this system is creating) with all tags present except + that the signature value(s) within the (s=) value are set to + the null string (""). The incomplete header field MUST be + unfolded, MUST have a trailing CRLF and MUST have spaces removed + in just the same way as the + complete header fields being processed. + */ + if messageInstances, ok := headers[hdrMessageInstance]; ok { + miHeaders := make([]indexedHeader, 0, len(messageInstances)) + for _, mi := range messageInstances { + ih, err := indexHeader(lwrMessageInstance, mi, "m") + if err != nil { + return nil, err + } + miHeaders = append(miHeaders, ih) + } + // If there are two headers with identical index something + // is wrong with the header, so we'll never compare the payload, + // but... + slices.SortFunc(miHeaders, func(a, b indexedHeader) int { + return cmp.Or(cmp.Compare(a.index, b.index), + bytes.Compare(a.payload, b.payload)) + }) + if d.MIRevision == 0 { + d.MIRevision = miHeaders[len(miHeaders)-1].index + } + for _, ih := range miHeaders { + payload = append(payload, ih.payload...) + } + } + + if dkim2Headers, ok := headers[hdrDKIM2Signature]; ok { + d2Headers := make([]indexedHeader, 0, len(dkim2Headers)) + for _, d2 := range dkim2Headers { + dh, err := indexHeader(lwrDKIM2Signature, d2, "i") + if err != nil { + return nil, err + } + d2Headers = append(d2Headers, dh) + } + slices.SortFunc(d2Headers, func(a, b indexedHeader) int { + return cmp.Or(cmp.Compare(a.index, b.index), + bytes.Compare(a.payload, b.payload)) + }) + if d.Sequence == 0 { + d.Sequence = d2Headers[len(d2Headers)-1].index + } + for _, dh := range d2Headers { + payload = append(payload, dh.payload...) + } + } + + for i, v := range d.Signatures { + v.Signature = []byte{} + d.Signatures[i] = v + } + d2, err := indexHeader(lwrDKIM2Signature, d.ToString(), "i") + if err != nil { + return nil, err + } + payload = append(payload, d2.payload...) + return payload, nil +} + +func (d *Signature) AddSignature(selector string, signer crypto.Signer, payload []byte) error { + var name string + var sig []byte + var err error + hasher := sha256.New() + _, _ = hasher.Write(payload) + hashed := hasher.Sum(nil) + switch signer.Public().(type) { + case *rsa.PublicKey: + name = "rsa-sha256" + sig, err = signer.Sign(rand.Reader, hashed, crypto.SHA256) + case ed25519.PublicKey: + name = "ed25519-sha256" + sig, err = signer.Sign(rand.Reader, hashed, crypto.Hash(0)) + default: + return fmt.Errorf("dkim2: unsupported key algorithm %T", signer.Public()) + } + if err != nil { + return err + } + d.Signatures = append(d.Signatures, Sig{ + Selector: selector, + Name: name, + Signature: sig, + }) + return nil +} + +// ToString converts a signature to a folded header. +func (d *Signature) ToString() string { + var b strings.Builder + _, _ = fmt.Fprintf(&b, "i=%d; m=%d; t=%d; d=%s", d.Sequence, d.MIRevision, d.Timestamp, d.Domain) + var rcptTos []string + for _, addr := range d.RcptTo { + rcptTos = append(rcptTos, base64.StdEncoding.EncodeToString([]byte(addr))) + } + _, _ = fmt.Fprintf(&b, ";\r\n mf=%s;\r\n rt=%s", + base64.StdEncoding.EncodeToString([]byte(d.MailFrom)), + strings.Join(rcptTos, ",\r\n ")) + if d.Nonce != "" { + _, _ = fmt.Fprintf(&b, ";\r\n n=%s;", d.Nonce) + } + var sigs []string + for _, sig := range d.Signatures { + sigs = append(sigs, sig.String()) + } + _, _ = fmt.Fprintf(&b, ";\r\n s=%s", strings.Join(sigs, ",\r\n ")) + + allFlags := d.ExtraFlags + if d.Exploded { + allFlags = append(allFlags, "exploded") + } + if d.DoNotExplode { + allFlags = append(allFlags, "donotexplode") + } + if d.DoNotModify { + allFlags = append(allFlags, "donotmodify") + } + if d.Feedback { + allFlags = append(allFlags, "feedback") + } + if len(allFlags) > 0 { + _, _ = fmt.Fprintf(&b, ";\r\n f=%s", strings.Join(allFlags, ",")) + } + return b.String() +} + +func isAsciiString(s []byte) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true +} + +func isPrintString(s []byte) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + if !unicode.IsPrint(rune(c)) { + return false + } + } + return true +} + +// Dkim2SignatureHeaders parses a slice of Dkim2-Signature +// headers and returns them as a list sorted by sequence (i=) +func Dkim2SignatureHeaders(headers []string) ([]*Signature, error) { + d2Headers := make([]*Signature, len(headers)) + for i, h := range headers { + d2, err := ParseSignature(h) + if err != nil { + return nil, err + } + d2Headers[i] = d2 + } + slices.SortFunc(d2Headers, func(a, b *Signature) int { + return cmp.Compare(a.Sequence, b.Sequence) + }) + return d2Headers, nil +} diff --git a/stevea/dns.go b/stevea/dns.go new file mode 100644 index 0000000..2e2c689 --- /dev/null +++ b/stevea/dns.go @@ -0,0 +1,88 @@ +package dkim2 + +import ( + "context" + "errors" + "fmt" + "strings" + + "codeberg.org/miekg/dns" + "codeberg.org/miekg/dns/dnsconf" + "codeberg.org/miekg/dns/dnsutil" +) + +var ResolveConf = "/etc/resolv.conf" + +type DnsResolver struct { + Client *dns.Client + Nameserver string +} + +var _ KeyResolver = DnsResolver{} + +func (d DnsResolver) Resolve(ctx context.Context, selector string, domain string) ([]string, error) { + hostname := HostnameForKey(selector, domain) + m := dns.NewMsg(hostname, dns.TypeTXT) + if m == nil { + return nil, errors.New("dns.NewMsg returned nil for type TXT; this shouldn't happen") + } + m.UDPSize = dns.DefaultMsgSize + r, _, err := d.Client.Exchange(ctx, m, "udp", d.Nameserver) + if err != nil { + return nil, err + } + if r.Rcode == dns.RcodeSuccess { + // NOERROR + response := []string{} + for _, a := range r.Answer { + if x, ok := a.(*dns.TXT); ok { + if dns.EqualName(hostname, x.Header().Name) { + response = append(response, strings.Join(x.Txt, "")) + } + return response, nil + } + } + } + if r.Rcode == dns.RcodeNameError { + // NXDOMAIN + return []string{}, nil + } + return nil, fmt.Errorf("failed to resolve %s, got response %s", hostname, dns.RcodeToString[r.Rcode]) +} + +// NewDnsResolver returns a KeyResolver that queries DNS. If the provided +// nameserver (hostname:port) is empty then /etc/resolv.conf will be read +// to find a default nameserver. +func NewDnsResolver(nameserver string) (*DnsResolver, error) { + if nameserver == "" { + conf, err := dnsconf.FromFile(ResolveConf) + if err != nil { + return nil, err + } + if len(conf.Servers) == 0 { + return nil, fmt.Errorf("no nameservers found in %s", ResolveConf) + } + nameserver = conf.Servers[0] + if conf.Port == "" { + nameserver = nameserver + ":53" + } else { + nameserver = nameserver + ":" + conf.Port + } + } + return &DnsResolver{ + Client: new(dns.Client), + Nameserver: nameserver, + }, nil +} + +var _ KeyResolver = DnsResolver{} + +//TODO(steve): Use net.LookupTXT() + +func HostnameForKey(selector, domain string) string { + var builder strings.Builder + builder.WriteString(selector) + builder.WriteString("._domainkey.") + builder.WriteString(domain) + return dnsutil.Canonical(builder.String()) +} diff --git a/stevea/edit_message.go b/stevea/edit_message.go new file mode 100644 index 0000000..eaadfb5 --- /dev/null +++ b/stevea/edit_message.go @@ -0,0 +1,27 @@ +package dkim2 + +import ( + "io" + "net/mail" +) + +type Editor struct { + headerCount map[string]int + bodyReader io.Reader + bodyCount int + recipe Recipe +} + +func NewEditor(before mail.Message) *Editor { + return nil +} + +func (e *Editor) SetHeaders(name string, values []string) {} + +func (e *Editor) AddHeaders(name string, values []string) {} + +func (e *Editor) DeleteHeader(name string) {} + +func (e *Editor) PrependBody([]string) {} + +func (e *Editor) AppendBody([]string) {} diff --git a/stevea/error_names.txt b/stevea/error_names.txt new file mode 100644 index 0000000..512e144 --- /dev/null +++ b/stevea/error_names.txt @@ -0,0 +1,21 @@ +ErrMessageInstanceMissing: PERMERROR Message-Instance m= missing +ErrMessageInstanceSyntaxError(Header, Detail, Err): PERMERROR Message-Instance m= syntax error +ErrMessageInstanceTagMissing(Header): PERMERROR Message-Instance m= tag= missing +ErrMessageInstanceIsNotSigned: PERMERROR Message-Instance m= is not signed +ErrSignatureMissing: PERMERROR DKIM2-Signature i= missing +ErrSignatureSyntaxError(Header, Err): PERMERROR DKIM2-Signature i= syntax error +ErrSignatureTagMissing(Header): PERMERROR DKIM2-Signature i= tag= missing +ErrSignatureExpired: PERMERROR DKIM2-Signature i= signature expired +ErrMailFromValueDidNotMatch: PERMERROR: MAIL FROM did not match +ErrRcptToValueDidNotMatch: PERMERROR: RCPT TO did not match +ErrMailFromAndDoNotMatch: PERMERROR: MAIL FROM and d= do not match +ErrTempSignaturePublicKeyValueCouldNotBeFetched(Err): TEMPERROR: DKIM2-Signature i= public key could not be fetched +ErrSignaturePublicKeyValueDoesNotExist: PERMERROR: DKIM2-Signature i= public key does not exist +ErrSignaturePublicKeyValueHasMultipleRecords: PERMERROR: DKIM2-Signature i= public key has multiple records +ErrSignaturePublicKeyValueHasASyntaxError(Err,Detail): PERMERROR: DKIM2-Signature i= public key has a syntax error +ErrSignaturePublicKeyValueAlgorithmMismatch: PERMERROR: DKIM2-Signature i= public key algorithm mismatch +ErrSignaturePublicKeyValueHasBeenRevoked: PERMERROR: DKIM2-Signature i= public key has been revoked +ErrFailSignaturePublicKeyValueIncorrectSignature(Detail,Err): FAIL: DKIM2-Signature i= public key incorrect signature +ErrFailMessageInstanceHeaderHashValueMismatch(Detail): FAIL: Message Instance m= header hash mismatch +ErrFailMessageInstanceBodyHashValueMismatch(Detail): FAIL: Message Instance m= body hash mismatch +ErrFailSignatureValidationFailed(Detail): FAIL: DKIM2-Signature i= validation failed diff --git a/stevea/errors.go b/stevea/errors.go new file mode 100644 index 0000000..9456a69 --- /dev/null +++ b/stevea/errors.go @@ -0,0 +1,72 @@ +package dkim2 + +import "fmt" + +// ErrMissingTag is returned when parsing a DKIM2-Signature +// or Message-Instance header and a required tag is missing +type ErrMissingTag struct { + Name string + V string + Tag string +} + +func (e ErrMissingTag) Error() string { + return fmt.Sprintf("Missing tag '%s' in %s header", e.Tag, e.Name) +} + +// ErrInvalidTag is returned when parsing a DKIM2-Signature +// or Message-Instance header and a tag isn't valid. +type ErrInvalidTag struct { + Name string + V string + Tag string + TagVal string + Err error +} + +func (e ErrInvalidTag) Error() string { + return fmt.Sprintf("Malformed tag %q in %s header: %s", e.TagVal, e.Name, e.Err) +} + +func (e ErrInvalidTag) Unwrap() error { + return e.Err +} + +// ErrMalformedHeader is returned when failing to parse a +// DKIM2-Signature or Message-Instance header. +type ErrMalformedHeader struct { + Name string + V string + Err error + Idx int +} + +func (e ErrMalformedHeader) Error() string { + return fmt.Sprintf("Malformed %s header %q: %s", e.Name, e.V, e.Err) +} + +func (e ErrMalformedHeader) Unwrap() error { + return e.Err +} + +// ErrRequiresRecipe is returned when trying to add a signature +// to a previously signed message where the hash has changed +// and no recipe was provided. +type ErrRequiresRecipe struct{} + +func (e ErrRequiresRecipe) Error() string { + return "Message hash has changed and no recipe was provided" +} + +// ErrDuplicateOrdering is returned when there are multiple +// headers with the same i= (DKIM2-Signature) or +//m= (Message-Instance) + +type ErrDuplicateOrdering struct { + Tag string + TagVal int +} + +func (e ErrDuplicateOrdering) Error() string { + return fmt.Sprintf("Duplicate ordering for tag %s=%d", e.Tag, e.TagVal) +} diff --git a/stevea/go.mod b/stevea/go.mod new file mode 100644 index 0000000..d9087b5 --- /dev/null +++ b/stevea/go.mod @@ -0,0 +1,15 @@ +module go.turscar.ie/dkim2 + +go 1.26.1 + +require ( + cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9 // indirect + codeberg.org/miekg/dns v0.6.73 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/stevea/go.sum b/stevea/go.sum new file mode 100644 index 0000000..74c990c --- /dev/null +++ b/stevea/go.sum @@ -0,0 +1,18 @@ +cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9 h1:HTKPq2VHKT7EagjnkunOblImEGxQcOJxHdmG+lQJV5Q= +cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9/go.mod h1:pjXLOZUvlHDj9nF7WsB2ECQVxmwneZ/VVOAkMAnaxaE= +codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY= +codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c h1:8TRxBMS/YsupXoOiGKHr9ZOXo+5DezGWPgBAhBHEHto= +github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/stevea/hash.go b/stevea/hash.go new file mode 100644 index 0000000..a0172d2 --- /dev/null +++ b/stevea/hash.go @@ -0,0 +1,128 @@ +package dkim2 + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "maps" + "net/mail" + "slices" + "strings" +) + +type HashSet struct { + Name string + HeaderHash []byte + BodyHash []byte +} + +func (h HashSet) String() string { + var b strings.Builder + b.WriteString(h.Name) + b.WriteRune(':') + b.WriteString(base64.StdEncoding.EncodeToString(h.HeaderHash)) + b.WriteRune(':') + b.WriteString(base64.StdEncoding.EncodeToString(h.BodyHash)) + return b.String() +} + +func (h HashSet) Equal(other HashSet) bool { + return bytes.Equal(h.HeaderHash, other.HeaderHash) && + bytes.Equal(h.BodyHash, other.BodyHash) && + h.Name == other.Name +} + +func (h HashSet) ContainedBy(others []HashSet) bool { + return slices.ContainsFunc(others, func(other HashSet) bool { + return h.Equal(other) + }) +} + +type ErrInvalidHashName struct { + Name string +} + +func (e ErrInvalidHashName) Error() string { + return fmt.Sprintf("invalid hash name: %q", e.Name) +} + +func HashMessage(body io.Reader, header map[string][]string, hashNames ...string) ([]HashSet, error) { + // The function can perform multiple hashes, if DKIM2 + // ever supports them other than theoretically. + _ = hashNames + bodyHasher := sha256.New() + headerHasher := sha256.New() + HashHeaders(headerHasher, header) + HashBody(bodyHasher, body) + return []HashSet{{ + Name: "sha256", + HeaderHash: headerHasher.Sum(nil), + BodyHash: bodyHasher.Sum(nil), + }}, nil +} + +// HashHeaders hashes the headers of an email, after they have +// been normalized by NormalizedHeaders +func HashHeaders(hash io.Writer, headers map[string][]string) { + for _, key := range slices.Sorted(maps.Keys(headers)) { + lines := headers[key] + for _, line := range slices.Backward(lines) { + hash.Write([]byte(key)) + hash.Write([]byte(":")) + hash.Write([]byte(line)) + hash.Write([]byte("\r\n")) + } + } +} + +// HashBody hashes the content of the body, with line-endings +// converted to CRLF, any blank lines at the end of the body +// excluded and a trailing CRLF. +func HashBody(hash io.Writer, body io.Reader) { + scanner := bufio.NewScanner(body) + hasTrailingCRLF := false + blankLinesCount := 0 + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + blankLinesCount++ + continue + } + for blankLinesCount > 0 { + _, _ = hash.Write([]byte("\r\n")) + blankLinesCount-- + } + _, _ = hash.Write([]byte(line)) + _, _ = hash.Write([]byte("\r\n")) + hasTrailingCRLF = true + } + if !hasTrailingCRLF { + _, _ = hash.Write([]byte("\r\n")) + } +} + +// NormalizedHeaders returns the headers of an email, +// unfolded, with keys folded to lower case and with +// headers that should not be part of a signature removed. +func NormalizedHeaders(headers mail.Header) map[string][]string { + ret := make(map[string][]string, len(headers)) + for k, v := range headers { + k = strings.ToLower(k) + switch k { + case "received", "return-path", "message-instance", "dkim2-signature", "dkim-signature": + continue + } + if strings.HasPrefix(k, "x-") || strings.HasPrefix(k, "arc-") { + continue + } + values := make([]string, 0, len(v)) + for _, val := range v { + values = append(values, strings.TrimSpace(whitespaceRe.ReplaceAllString(val, " "))) + } + ret[strings.ToLower(k)] = values + } + return ret +} diff --git a/stevea/headers.go b/stevea/headers.go new file mode 100644 index 0000000..b9f8251 --- /dev/null +++ b/stevea/headers.go @@ -0,0 +1,34 @@ +package dkim2 + +import ( + "bytes" + "io" + "net/mail" + "regexp" +) + +const ( + hdrMessageInstance = "Message-Instance" + lwrMessageInstance = "message-instance" + hdrDKIM2Signature = "Dkim2-Signature" + lwrDKIM2Signature = "dkim2-signature" +) + +var blankLineRe = regexp.MustCompile(`\n\r?\n`) + +// ReadMessage reads an email from a reader and returns +// the raw headers and the mail as a mail.Message. +func ReadMessage(r io.Reader) ([]byte, *mail.Message, error) { + var header bytes.Buffer + t := io.TeeReader(r, &header) + msg, err := mail.ReadMessage(t) + if err != nil { + return nil, nil, err + } + rawHeader := header.Bytes() + eoh := blankLineRe.FindIndex(rawHeader) + if eoh != nil { + rawHeader = rawHeader[:eoh[0]+1] + } + return rawHeader, msg, nil +} diff --git a/stevea/headers_test.go b/stevea/headers_test.go new file mode 100644 index 0000000..a1a2382 --- /dev/null +++ b/stevea/headers_test.go @@ -0,0 +1,60 @@ +package dkim2 + +import ( + "io" + "net/mail" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestReadMessage(t *testing.T) { + tests := map[string]struct { + Input string + WantRawHeader string + WantBody string + WantParsedHeaders mail.Header + WantErr bool + }{ + "simple": { + Input: "from: \r\n" + + "subject: Hello World\r\n" + + "\r\n" + + "Body with no trailing CRLF", + WantRawHeader: "from: \r\n" + + "subject: Hello World\r\n", + WantParsedHeaders: mail.Header{ + "From": []string{""}, + "Subject": []string{"Hello World"}, + }, + WantBody: "Body with no trailing CRLF", + WantErr: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + reader := strings.NewReader(tc.Input) + raw, parsed, err := ReadMessage(reader) + if (err != nil) != tc.WantErr { + t.Errorf("error = %v, WantErr %v", err, tc.WantErr) + } + if err == nil { + if diff := cmp.Diff(tc.WantRawHeader, string(raw)); diff != "" { + t.Errorf("raw header mismatch (-want +got):\n%s", diff) + } + bodyRemainder, err := io.ReadAll(parsed.Body) + if err != nil { + t.Errorf("error reading body remainder: %v", err) + } + if diff := cmp.Diff(tc.WantBody, string(bodyRemainder)); diff != "" { + t.Errorf("body mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.WantParsedHeaders, parsed.Header); diff != "" { + t.Errorf("parsed headers mismatch (-want +got):\n%s", diff) + } + + } + }) + } +} diff --git a/stevea/internal/generate/validationerrors/validationerrors.go b/stevea/internal/generate/validationerrors/validationerrors.go new file mode 100644 index 0000000..351218e --- /dev/null +++ b/stevea/internal/generate/validationerrors/validationerrors.go @@ -0,0 +1,261 @@ +package main + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "go/format" + "log" + "os" + "regexp" + "slices" + "strings" + "text/template" + + "github.com/iancoleman/strcase" +) + +type Param struct { + Name string + Type string +} +type Message struct { + Name string + Content string + Parameters []Param + HiddenParameters []Param + Template string + State string + HasErr bool + HasDetail bool +} + +const structsTpl = `// Code generated by validationerrors. DO NOT EDIT. + +package dkim2 + +import "fmt" + +{{ range . }} +// {{ .Content }} +type {{ .Name }} struct { +{{- range .Parameters }} + {{ .Name }} {{ .Type }}{{ end -}} +{{- range .HiddenParameters }} + {{ .Name }} {{ .Type }}{{ end -}} +} + +func (e {{ .Name }}) Error() string { +{{ if .HasDetail }} + s := fmt.Sprintf("{{ .Template }}"{{ range .Parameters }}, e.{{ .Name }}{{ end }}) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" +{{ else }} + return fmt.Sprintf("{{ .Template }}"{{ range .Parameters }}, e.{{ .Name }}{{ end }}) +{{ end }} +} + +func (_ {{ .Name }}) State() VerificationState { + return {{ .State }} +} + +{{ if .HasErr -}} +func (e {{ .Name }}) Unwrap() error { + return e.Err +} +{{ end }} + +{{ end }} +` + +var nofmt = false + +func main() { + var labelFile, specFile string + var printLabels bool + var outputFile string + + flag.StringVar(&labelFile, "label", "", "Label file to read") + flag.StringVar(&specFile, "spec", "", "Spec file to read") + flag.BoolVar(&printLabels, "print-labels", false, "Print labels to stdout") + flag.StringVar(&outputFile, "output", "", "Go file to write") + flag.BoolVar(&nofmt, "nofmt", false, "Do not format code") + + flag.Parse() + if printLabels { + f, err := os.Open(specFile) + if err != nil { + log.Fatal(err) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + + errorRe := regexp.MustCompile(`^\s+(?:PERMERROR|TEMPERROR|FAIL):?`) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if errorRe.MatchString(line) { + content := strings.TrimSpace(line) + name := []string{"Err"} + words := strings.Fields(content) + for _, word := range words { + switch { + case strings.HasPrefix(word, "PERMERROR"): + case strings.HasPrefix(word, "TEMPERROR"): + name = append(name, "temp") + case strings.HasPrefix(word, "FAIL"): + name = append(name, "fail") + case word == "DKIM2-Signature": + name = append(name, "signature") + case word == "Message-Instance": + name = append(name, "message", "instance") + case strings.HasPrefix(word, "tag="): + name = append(name, "tag") + case strings.Contains(word, "="): + case strings.Contains(word, "<"): + name = append(name, strings.Trim(word, "<>")) + default: + name = append(name, word) + } + } + fmt.Printf("%s: %s\n", strcase.ToCamel(strings.Join(name, "_")), content) + } + } + return + } + + if labelFile != "" { + f, err := os.Open(labelFile) + if err != nil { + log.Fatal(err) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + paramRe := regexp.MustCompile("(?:[a-z]+=)?<[a-z]+>") + stateRe := regexp.MustCompile(`^(FAIL|TEMPERROR|PERMERROR)`) + extraRe := regexp.MustCompile(`([A-Za-z0-9]+)\(([^)]*)\)`) + scanner := bufio.NewScanner(f) + var msgs []Message + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, ": ", 2) + matches := stateRe.FindStringSubmatch(parts[1]) + if matches == nil { + log.Fatalf("invalid state: %s", parts[1]) + } + state := matches[1] + name := parts[0] + hasErr := false + hasDetail := false + + var extraParams []Param + matches = extraRe.FindStringSubmatch(name) + if matches != nil { + name = matches[1] + for _, extra := range strings.Split(matches[2], ",") { + extra = strings.TrimSpace(extra) + switch extra { + case "Err": + extraParams = append(extraParams, Param{ + Name: "Err", + Type: "error", + }) + hasErr = true + case "Detail": + extraParams = append(extraParams, Param{ + Name: "Detail", + Type: "string", + }) + hasDetail = true + default: + extraParams = append(extraParams, Param{ + Name: extra, + Type: "string", + }) + } + } + } + + var stateName string + switch state { + case "FAIL": + stateName = "StateFail" + case "TEMPERROR": + stateName = "StateTempError" + case "PERMERROR": + stateName = "StatePermError" + } + + msg := Message{ + Name: name, + Content: strings.TrimSpace(parts[1]), + State: stateName, + HiddenParameters: extraParams, + HasErr: hasErr, + HasDetail: hasDetail, + } + + msg.Template = paramRe.ReplaceAllStringFunc(msg.Content, func(s string) string { + switch { + case strings.HasPrefix(s, "i="): + msg.Parameters = append(msg.Parameters, Param{ + Name: "Sequence", + Type: "int", + }) + return "i=%d" + case strings.HasPrefix(s, "m="): + msg.Parameters = append(msg.Parameters, Param{ + Name: "MInstance", + Type: "int", + }) + return "m=%d" + case strings.HasPrefix(s, "tag="): + msg.Parameters = append(msg.Parameters, Param{ + Name: "Tag", + Type: "string", + }) + return "tag=%s" + default: + msg.Parameters = append(msg.Parameters, Param{ + Name: "Value", + Type: "string", + }) + return "%s" + } + }) + msgs = append(msgs, msg) + } + slices.SortFunc(msgs, func(a, b Message) int { + return strings.Compare(a.Name, b.Name) + }) + err = generateFile(outputFile, structsTpl, msgs) + if err != nil { + log.Fatal(err) + } + } +} + +func generateFile(filename string, tpl string, data any) error { + t, err := template.New("").Option("missingkey=error").Parse(tpl) + if err != nil { + return err + } + buf := new(bytes.Buffer) + err = t.Execute(buf, data) + if err != nil { + return err + } + generated := buf.Bytes() + if !nofmt { + generated, err = format.Source(generated) + if err != nil { + return err + } + } + return os.WriteFile(filename, generated, 0644) +} diff --git a/stevea/interop_test.go b/stevea/interop_test.go new file mode 100644 index 0000000..2494309 --- /dev/null +++ b/stevea/interop_test.go @@ -0,0 +1,74 @@ +package dkim2 + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" +) + +const dnsFile = "../dns.json" + +func TestInterop_Python(t *testing.T) { + emailDir := "../python/tests/expected" + _, err := os.Stat(emailDir) + if err != nil && errors.Is(err, fs.ErrNotExist) { + t.Skipf("skipping test because %s does not exist", emailDir) + } + messages, err := filepath.Glob(filepath.Join(emailDir, "*.eml")) + if err != nil { + t.Fatal(err) + } + for _, message := range messages { + name := filepath.Base(message) + t.Run(name, func(t *testing.T) { + if name == "emptybody-ed25519.eml" { + t.Skipf("skipping %s as we fail on it due to bare CR", name) + } + f, err := os.Open(message) + if err != nil { + t.Fatal(err) + } + verifyOpts := VerifyOptions{ + Resolver: NewTestResolver(dnsFile), + IgnoreTimestamp: true, + } + result := Verify(context.Background(), f, verifyOpts) + if result.State() != StatePass { + t.Errorf("Verify() got %s (%v), want %s", result.Err, errors.Unwrap(result.Err), StatePass) + + } + }) + } +} + +func TestInterop_Brong(t *testing.T) { + emailDir := "../brong/tests/expected" + _, err := os.Stat(emailDir) + if err != nil && errors.Is(err, fs.ErrNotExist) { + t.Skipf("skipping test because %s does not exist", emailDir) + } + messages, err := filepath.Glob(filepath.Join(emailDir, "*.eml")) + if err != nil { + t.Fatal(err) + } + for _, message := range messages { + name := filepath.Base(message) + t.Run(name, func(t *testing.T) { + f, err := os.Open(message) + if err != nil { + t.Fatal(err) + } + verifyOpts := VerifyOptions{ + Resolver: NewTestResolver(dnsFile), + IgnoreTimestamp: true, + } + result := Verify(context.Background(), f, verifyOpts) + if result.State() != StatePass { + t.Errorf("Verify() got %s (%v), want %s", result.Err, errors.Unwrap(result.Err), StatePass) + } + }) + } +} diff --git a/stevea/messageinstance.go b/stevea/messageinstance.go new file mode 100644 index 0000000..3648c0d --- /dev/null +++ b/stevea/messageinstance.go @@ -0,0 +1,315 @@ +package dkim2 + +import ( + "cmp" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/mail" + "regexp" + "slices" + "strconv" + "strings" +) + +type MessageInstance struct { + Revision int + Hashes []HashSet + Recipes *Recipe + Original string +} + +var base64Regexp = regexp.MustCompile(`^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$`) + +// ParseMessageInstance parses the contents of a Message-Instance header +func ParseMessageInstance(s string) (*MessageInstance, error) { + tags, err := NewTags(hdrMessageInstance, s, "m") + if err != nil { + return nil, err + } + rev, ok := tags.Get("m") + if !ok { + return nil, ErrMissingTag{ + Name: hdrMessageInstance, + V: s, + Tag: "m", + } + } + revI, err := strconv.ParseInt(rev, 10, 32) + if err != nil { + return nil, ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "m", + TagVal: rev, + Err: err, + } + } + + ret := MessageInstance{ + Revision: int(revI), + Original: s, + } + + hashes, ok := tags.Get("h") + if !ok { + return nil, ErrMessageInstanceTagMissing{ + MInstance: ret.Revision, + Tag: "h", + Header: s, + } + } + + hashList := strings.Split(hashes, ",") + for _, h := range hashList { + parts := strings.Split(h, ":") + if len(parts) != 3 { + return nil, ErrMessageInstanceSyntaxError{ + MInstance: ret.Revision, + Header: s, + Err: ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "h", + TagVal: h, + Err: errors.New("should have three components"), + }, + } + } + headerHash := removeWhitespace(parts[1]) + bodyHash := removeWhitespace(parts[2]) + hs := HashSet{ + Name: strings.TrimSpace(parts[0]), + } + hs.HeaderHash, err = base64.StdEncoding.Strict().DecodeString(headerHash) + if err != nil { + return nil, ErrMessageInstanceSyntaxError{ + MInstance: ret.Revision, + Header: s, + Err: ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "h", + TagVal: h, + Err: fmt.Errorf("in header hash: %w", err), + }, + } + } + + hs.BodyHash, err = base64.StdEncoding.Strict().DecodeString(bodyHash) + if err != nil { + return nil, ErrMessageInstanceSyntaxError{ + MInstance: ret.Revision, + Header: s, + Err: ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "h", + TagVal: h, + Err: fmt.Errorf("in body hash: %w", err), + }, + } + } + ret.Hashes = append(ret.Hashes, hs) + } + + encodedRecipes, ok := tags.Get("r") + if !ok { + return &ret, nil + } + + encodedRecipes = removeWhitespace(encodedRecipes) + //if !base64Regexp.MatchString(recipes) { + // return nil, ErrInvalidRTag{errors.New("recipe (r=) has invalid base64")} + //} + + jsonRecipe, err := base64.StdEncoding.Strict().DecodeString(encodedRecipes) + if err != nil { + return nil, ErrMessageInstanceSyntaxError{ + MInstance: ret.Revision, + Header: s, + Err: ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "r", + TagVal: encodedRecipes, + Err: err, + }, + } + } + var recipe Recipe + err = json.Unmarshal(jsonRecipe, &recipe) + if err != nil { + return nil, ErrMessageInstanceSyntaxError{ + MInstance: ret.Revision, + Header: s, + Err: ErrInvalidTag{ + Name: hdrMessageInstance, + V: s, + Tag: "r", + TagVal: encodedRecipes, + Err: err, + }, + } + } + ret.Recipes = &recipe + + return &ret, nil +} + +func (m MessageInstance) ToString() (string, error) { + var b strings.Builder + _, _ = fmt.Fprintf(&b, "m=%d; h=", m.Revision) + for i, h := range m.Hashes { + if i != 0 { + b.WriteString(",\r\n ") + } + b.WriteString(h.Name) + b.WriteString(":") + b.WriteString(base64.StdEncoding.EncodeToString(h.HeaderHash)) + b.WriteString(":") + b.WriteString(base64.StdEncoding.EncodeToString(h.BodyHash)) + } + //b.WriteString(";") + if m.Recipes == nil { + return b.String(), nil + } + b.WriteString(";\r\n r=") + encoded, err := json.Marshal(m.Recipes) + if err != nil { + return "", ErrInvalidTag{ + Name: hdrMessageInstance, + V: "", + Tag: "r", + TagVal: "", + Err: err, + } + } + b64 := make([]byte, base64.StdEncoding.EncodedLen(len(encoded))) + base64.StdEncoding.Encode(b64, encoded) + var chunkLen = 70 + for len(b64) < chunkLen { + b.Write(b64[:chunkLen]) + b.WriteString("\r\n ") + b64 = b64[chunkLen:] + } + b.Write(b64) + //b.WriteString(";") + return b.String(), nil +} + +// MessageInstanceHeaders parses a slice of Message-Instance header values +// and returns them as a list sorted by revision (m=) +func MessageInstanceHeaders(headers []string) ([]*MessageInstance, error) { + miHeaders := make([]*MessageInstance, len(headers)) + for i, h := range headers { + mi, err := ParseMessageInstance(h) + if err != nil { + return nil, err + } + miHeaders[i] = mi + } + slices.SortFunc(miHeaders, func(a, b *MessageInstance) int { + return cmp.Compare(a.Revision, b.Revision) + }) + return miHeaders, nil +} + +var whitespaceRe = regexp.MustCompile(`\s+`) + +func removeWhitespace(s string) string { + return whitespaceRe.ReplaceAllLiteralString(s, "") +} + +func NewMessageInstance(m *mail.Message) (*MessageInstance, error) { + h := NormalizedHeaders(m.Header) + + // Find the highest revision. We don't fully parse or validate existing Message-Instance headers. + revision := 1 + existingMIeaders, ok := h["message-instance"] + if ok { + for _, miHeader := range existingMIeaders { + tags, err := NewTags(hdrMessageInstance, miHeader, "m") + if err == nil { + m, ok := tags.Get("m") + if ok { + rev, err := strconv.ParseInt(m, 10, 32) + if err == nil && int(rev) >= revision { + revision = int(rev) + 1 + } + } + } + } + } + + hashes, err := HashMessage(m.Body, NormalizedHeaders(m.Header)) + if err != nil { + return nil, err + } + ret := &MessageInstance{ + Revision: revision, + Hashes: hashes, + } + return ret, nil +} + +/* + +A Message-Instance header field documents the current contents of +the message and, in the case of a Reviser, records any relevant +changes that have been made to the incoming message. + +The Message-Instance header field is a list of tag values as described +below. The m= and h= tags MUST be present. The r= tag is optional. + +The tag identifiers (before the = sign) MUST be treated as case +insignificant, the tag value (after the = sign) is case significant. The +tags may appear in any order, but MUST be only one of each kind. Unknown +tags, for extensions, MUST be ignored. + +ABNF: + + mi-field = "Message-Instance:" mi-tag-list + mi-tag-list = *([FWS] mi-tag [FWS] ";" [FWS]) + mi-tag = mi-m-tag / mi-h-tag / mi-r-tag / x-tag + x-tag = ALPHA *(ALPHA / DIGIT / "_") "=" %x21-3A / %x3C-7E + ; for extension + +## m= the revision number of the Message-Instance header field + +The Originator of a message uses the +value 1. Further Message-Instance header fields are added with a value one +more than the current highest numbered Message-Instance header field. Gaps +in the numbering MUST be treated as making the whole message impossible +to verify. + +ABNF: + + mi-m-tag = %x6d [FWS] "=" [FWS] 1*DIGIT + +## r= recipes to recreate the previous instance of the message + +The r= tag value is the base64 encoded version of the JSON object that +contains the recipes that allow the previous instance of the message +to be recreated (see {{JSONrecipe}}). + +ABNF: + + mi-r-tag = %x72 [FWS] "=" base64string + +## h= the hash values for the message + +The h= tag value contains the hash name, header hash value and body +hash value. Calculating the hash values is explained in {{messagehashes}}. + +ABNF: + + mi-h-tag = %x68 [FWS] "=" hash-set *("," hash-set ) + hash-set = [FWS] hash-name [FWS] ":" header-hash ":" body-hash + hash-name = "sha256" / x-hash-name + header-hash = base64string + body-hash = base64string + x-hash-name = textstring ; for later expansion + +*/ diff --git a/stevea/messageinstance_test.go b/stevea/messageinstance_test.go new file mode 100644 index 0000000..88fb66b --- /dev/null +++ b/stevea/messageinstance_test.go @@ -0,0 +1,60 @@ +package dkim2 + +import ( + "net/mail" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +/* + +Message-Instance: m=1; +h=sha256: r+eDvDsRLH2RA6B/XHTZLWJ5rDoB6xqAh//Xuqzp7DU=:4M5VЗw0E9+JY04qХqLjV2r6WCKvUrlP0h9oupBC8sqw= +DKIM2-Signature: i=1; m=1; d=socketlabs.com; s=dkim: rsa-sha256:czuPJ+BxHt7rmavbihn2DuD34YE2WqXizHMzKuu8BeWeX0COmVzRbch6Wni26216I6QnIdBJenluX17IiC0gdbiWrvYZ4gl7G +mf=PGM40TAuODIuMWY0N2RkMDAwMDAwMGU30C4wNmFiZGU3ZjFmYzA3ZWFiZjUzYzk5ZTMy0Dg2ZGY4NkBzb2NrZXRsYWJzLmNvbT4=; +rt=PHRlc3RAW3JlZGFjdGVkXS5jb20+; t=1777074246 +*/ + +func TestMessageInstance_Parse(t *testing.T) { + tests := map[string]struct { + Header string + Want *MessageInstance + WantErr bool + }{ + "thankssl": { + Header: "Message-Instance: m=1;\r\n h=sha256: r+eDvDsRLH2RA6B/XHTZLWJ5rDoB6xqAh//Xuqzp7DU=:4M5V3wOE9+JY04qXqLjV2r6WCKvUrlP0h9oupBC8sqw=\r\n", + Want: &MessageInstance{ + Revision: 1, + Hashes: []HashSet{ + { + Name: "sha256", + HeaderHash: []byte{0xaf, 0xe7, 0x83, 0xbc, 0x3b, 0x11, 0x2c, 0x7d, 0x91, 0x3, 0xa0, 0x7f, 0x5c, 0x74, 0xd9, 0x2d, 0x62, 0x79, 0xac, 0x3a, 0x1, 0xeb, 0x1a, 0x80, 0x87, 0xff, 0xd7, 0xba, 0xac, 0xe9, 0xec, 0x35}, + BodyHash: []byte{0xe0, 0xce, 0x55, 0xdf, 0x3, 0x84, 0xf7, 0xe2, 0x58, 0xd3, 0x8a, 0x97, 0xa8, 0xb8, 0xd5, 0xda, 0xbe, 0x96, 0x8, 0xab, 0xd4, 0xae, 0x53, 0xf4, 0x87, 0xda, 0x2e, 0xa4, 0x10, 0xbc, 0xb2, 0xac}, + }, + }, + Recipes: nil, + Original: "m=1; h=sha256: r+eDvDsRLH2RA6B/XHTZLWJ5rDoB6xqAh//Xuqzp7DU=:4M5V3wOE9+JY04qXqLjV2r6WCKvUrlP0h9oupBC8sqw=", + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + msg, err := mail.ReadMessage(strings.NewReader(tc.Header)) + if err != nil { + t.Fatal(err) + } + mi, err := ParseMessageInstance(msg.Header["Message-Instance"][0]) + if (err != nil) != tc.WantErr { + t.Errorf("expected error %v, got %v", tc.WantErr, err) + } + if err == nil { + if diff := cmp.Diff(tc.Want, mi); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + } + //t.Logf("%#v\n", mi) + }) + } +} diff --git a/stevea/recipe.go b/stevea/recipe.go new file mode 100644 index 0000000..9ab5287 --- /dev/null +++ b/stevea/recipe.go @@ -0,0 +1,520 @@ +package dkim2 + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + "github.com/pkg/diff/myers" + "github.com/pkg/diff/write" +) + +type BodyReader struct { + r *bufio.Scanner + lineno int +} + +func (r *BodyReader) Scan() bool { + r.lineno++ + return r.r.Scan() +} + +func (r *BodyReader) Line() int { + return r.lineno +} + +func (r *BodyReader) Err() error { + return r.r.Err() +} + +func (r *BodyReader) Text() string { + return r.r.Text() +} + +func (r *BodyReader) Bytes() []byte { + return r.r.Bytes() +} + +func NewBodyReader(r io.Reader) *BodyReader { + return &BodyReader{ + r: bufio.NewScanner(r), + lineno: 0, + } +} + +type RecipeCopyHeaderStep []int + +func (s RecipeCopyHeaderStep) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string][]int{ + "c": s, + }) +} + +// ApplyHeader applies the copy step to the list of headers given. +// old headers are in newer...older order, output headers in older...newer +func (s RecipeCopyHeaderStep) ApplyHeader(oldHeaders []string, newHeaders []string) ([]string, error) { + if s[1] > len(oldHeaders) { + return nil, fmt.Errorf("copy range outside header count") + } + for i := s[0]; i <= s[1]; i++ { + newHeaders = append(newHeaders, oldHeaders[len(oldHeaders)-i]) + } + return newHeaders, nil +} + +type RecipeDataHeaderStep []string + +func (s RecipeDataHeaderStep) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string][]string{ + "d": s, + }) +} + +// ApplyHeader copies headers to the output. +// Output headers are in older...newer order +func (s RecipeDataHeaderStep) ApplyHeader(_ []string, newHeaders []string) ([]string, error) { + newHeaders = append(newHeaders, s...) + return newHeaders, nil +} + +type RecipeCopyBodyStep []int + +func (s RecipeCopyBodyStep) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string][]int{ + "c": s, + }) +} + +func (s RecipeCopyBodyStep) ApplyBody(r *BodyReader, w io.Writer) error { + for r.Line() < s[0] { + if !r.Scan() { + return r.Err() + } + } + for r.Line() <= s[1] { + _, err := io.WriteString(w, r.Text()) + if err != nil { + return err + } + if !r.Scan() { + return r.Err() + } + _, err = io.WriteString(w, "\r\n") + if err != nil { + return err + } + } + return nil +} + +type RecipeDataBodyStep []string + +func (s RecipeDataBodyStep) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string][]string{ + "d": s, + }) +} + +func (s RecipeDataBodyStep) ApplyBody(_ *BodyReader, w io.Writer) error { + for _, v := range s { + _, err := io.WriteString(w, v) + if err != nil { + return err + } + _, err = io.WriteString(w, "\r\n") + if err != nil { + return err + } + } + return nil +} + +type RecipeTruncatedBodyStep struct{} + +func (s RecipeTruncatedBodyStep) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]bool{ + "z": true, + }) +} + +func (s RecipeTruncatedBodyStep) ApplyBody(_ *BodyReader, w io.Writer) error { + //TODO implement me + panic("implement me") +} + +type RecipeHeaderStep interface { + ApplyHeader(oldHeaders []string, newHeaders []string) ([]string, error) +} + +type RecipeBodyStep interface { + ApplyBody(r *BodyReader, w io.Writer) error +} + +type RecipeHeaderSteps []RecipeHeaderStep + +func (r *RecipeHeaderSteps) UnmarshalJSON(data []byte) error { + var steps []map[string]json.RawMessage + if err := json.Unmarshal(data, &steps); err != nil { + return err + } + if steps == nil { + *r = nil + return nil + } + *r = make([]RecipeHeaderStep, 0, len(steps)) + for _, step := range steps { + if len(step) != 1 { + return fmt.Errorf("invalid recipe step: %v", step) + } + var prevEnd int + for k, v := range step { + switch k { + case "d": + var s RecipeDataHeaderStep + err := json.Unmarshal(v, &s) + if err != nil { + return fmt.Errorf("invalid recipe json: %w", err) + } + *r = append(*r, s) + case "c": + var s RecipeCopyHeaderStep + err := json.Unmarshal(v, &s) + if err != nil { + return fmt.Errorf("invalid recipe json: %w", err) + } + if len(s) != 2 { + return fmt.Errorf("recipe step should have two values: %v", s) + } + if s[0] > s[1] { + return fmt.Errorf("recipe copy step should have end >= start: %v", s) + } + if s[0] < 1 { + return fmt.Errorf("recipe copy step must have start >= 1: %v", s) + } + if s[0] <= prevEnd { + return fmt.Errorf("recipe copy start (%d) must be > previous end (%d)", s[0], prevEnd) + } + prevEnd = s[1] + *r = append(*r, s) + + default: + return fmt.Errorf("invalid recipe step key: %q", k) + } + } + } + return nil +} + +type RecipeBodySteps []RecipeBodyStep + +func (r *RecipeBodySteps) UnmarshalJSON(data []byte) error { + var steps []map[string]json.RawMessage + if err := json.Unmarshal(data, &steps); err != nil { + return err + } + if steps == nil { + *r = nil + return nil + } + *r = make([]RecipeBodyStep, 0, len(steps)) + for _, step := range steps { + if len(step) != 1 { + return fmt.Errorf("invalid recipe step: %v", step) + } + var prevEnd int + for k, v := range step { + switch k { + case "d": + var s RecipeDataBodyStep + err := json.Unmarshal(v, &s) + if err != nil { + return fmt.Errorf("invalid recipe json: %w", err) + } + *r = append(*r, s) + case "c": + var s RecipeCopyBodyStep + err := json.Unmarshal(v, &s) + if err != nil { + return fmt.Errorf("invalid recipe json: %w", err) + } + if len(s) != 2 { + return fmt.Errorf("recipe step should have two values: %v", s) + } + if s[0] > s[1] { + return fmt.Errorf("recipe copy step should have end >= start: %v", s) + } + if s[0] < 1 { + return fmt.Errorf("recipe copy step must have start >= 1: %v", s) + } + if s[0] <= prevEnd { + return fmt.Errorf("recipe copy start (%d) must be > previous end (%d)", s[0], prevEnd) + } + prevEnd = s[1] + *r = append(*r, s) + case "z": + var b bool + if err := json.Unmarshal(v, &b); err != nil { + return fmt.Errorf("invalid recipe json: %w", err) + } + if !b { + return fmt.Errorf("recipe step z is not true: %v", v) + } + *r = append(*r, RecipeTruncatedBodyStep{}) + default: + return fmt.Errorf("invalid recipe step key: %q", k) + } + } + } + return nil +} + +type RecipeHeaderMap map[string]RecipeHeaderSteps + +type Recipe struct { + HeaderRecipes *RecipeHeaderMap `json:"h,omitempty"` + BodyRecipes *RecipeBodySteps `json:"b,omitempty"` +} + +func (r *Recipe) UnmarshalJSON(data []byte) error { + var s struct { + HeaderRecipes *RecipeHeaderMap `json:"h"` + BodyRecipes *RecipeBodySteps `json:"b"` + } + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + r.HeaderRecipes = s.HeaderRecipes + r.BodyRecipes = s.BodyRecipes + if r.HeaderRecipes != nil { + for k := range *r.HeaderRecipes { + if strings.ToLower(k) != k { + return fmt.Errorf("invalid header name (must be lower case): %q", k) + } + } + } + return nil +} + +// Header applies the recipe to an email header represented as a map of string slices. +// The key of the map is the name of the header, folded to lower case. The slice is +// successive headers of that name from top to bottom. +func (r *Recipe) Header(current map[string][]string) (map[string][]string, error) { + if r.HeaderRecipes == nil { + // If there is no "h" field in the JSON object then there was no + // modification to the header fields. + return current, nil + } + if len(*r.HeaderRecipes) == 0 { + // If the "h" field value is null (there are no recipes for + // any header field) then the previous state of the header fields + // cannot be recreated. + return nil, nil + } + + result := make(map[string][]string, len(current)) + for k, v := range current { + // If a header field name is not present in the JSON object then all + // header fields with that header field name are to be retained. + _, ok := (*r.HeaderRecipes)[k] + if !ok { + result[k] = v + } + } + + for k, recipe := range *r.HeaderRecipes { + oldHeaders := current[k] + var newHeaders []string + for _, step := range recipe { + var err error + newHeaders, err = step.ApplyHeader(oldHeaders, newHeaders) + if err != nil { + return nil, err + } + } + if newHeaders != nil { + // ApplyHeader appends the headers we add to the end of the newHeaders slice, + // which is the reverse of what we want + slices.Reverse(newHeaders) + result[k] = newHeaders + } + } + return result, nil +} + +func (r *Recipe) Body(newBody io.Reader, w io.Writer) error { + if r.BodyRecipes == nil { + _, err := io.Copy(w, newBody) + return err + } + + reader := NewBodyReader(newBody) + for _, step := range *r.BodyRecipes { + err := step.ApplyBody(reader, w) + if err != nil { + return err + } + } + return nil +} + +// HeadersFromMailHeaders creates headers suitable for Recipe.Header() from a mail.Header, +// stripping out unwanted headers and normalizing the header payloads. + +// +// * Ignore some header fields +// +// When calculating the header field hash "Received" or "Return-Path" +// header fields MUST be ignored. +// These are Trace headers as described in [RFC5321] +// and serve only to document details of the SMTP transmission process. +// +// When calculating the header field hash any header field with +// a header field name starting with "X-" MUST be ignored. +// Currently deployed email systems use these fields as +// proprietary Trace headers which have no defined meaning for +// other systems and it considerably simplifies reporting +// on changes to header fields to ignore them. +// +// When calculating the header field hash any "Message-Instance" or +// "DKIM2-Signature" header fields MUST be ignored. These header +// fields will be included in the hash value that will be signed +// by a DKIM2-Signature header field and it simplifies implementations +// if they are not included twice, especially when determining +// whether all modifications to a message have been correctly declared. +// +// When calculating the header field hash any "DKIM-Signature" header +// fields and any header fields whose field name starts with "ARC-" +// MUST be ignored. Not including +// DKIM1 and ARC signatures means that systems that wish to add other +// types of signature as well as a DKIM2 signature are free to do this +// in any convenient order. +// +// * Convert all header field names (not the header field values) to +// lowercase. For example, convert "SUBJect: AbC" to "subject: AbC". +// +// * Unfold all header field continuation lines as described in +// [RFC5322]; in particular, lines with terminators embedded in +// continued header field values (that is, CRLF sequences followed by +// WSP) MUST be interpreted without the CRLF. Implementations MUST +// NOT remove the CRLF at the end of the header field value. +// +// * Convert all sequences of one or more WSP characters to a single SP +// character. WSP characters here include those before and after a +// line folding boundary. +// +// * Delete all WSP characters at the end of each unfolded header field +// value. +// +// * Delete any WSP characters remaining before and after the colon +// separating the header field name from the header field value. The +// colon separator MUST be retained. + +func headersEqual(a, b map[string][]string) bool { + for k := range b { + _, ok := a[k] + if !ok { + return false + } + } + for k, valuesA := range a { + valuesB, ok := b[k] + if !ok { + return false + } + if len(valuesA) != len(valuesB) { + return false + } + for i, payloadA := range valuesA { + if payloadA != valuesB[i] { + return false + } + } + } + return true +} + +type headerPair struct { + oldHeaders []string + newHeaders []string +} + +func (h headerPair) WriteATo(w io.Writer, ai int) (int, error) { + return io.WriteString(w, h.newHeaders[ai]) +} + +func (h headerPair) WriteBTo(w io.Writer, bi int) (int, error) { + return io.WriteString(w, h.oldHeaders[bi]) +} + +func (h headerPair) LenA() int { + return len(h.newHeaders) +} + +func (h headerPair) LenB() int { + return len(h.oldHeaders) +} + +func (h headerPair) Equal(ai, bi int) bool { + return h.newHeaders[ai] == h.oldHeaders[bi] +} + +var _ myers.Pair = headerPair{} +var _ write.Pair = headerPair{} + +/* +// diffHeaders creates a recipe to convert from newHeader to oldHeader +func diffHeaders(ctx context.Context, oldHeader, newHeader map[string][]string) *RecipeHeaderMap { + if headersEqual(oldHeader, newHeader) { + return nil + } + res := RecipeHeaderMap{} + for k, v := range oldHeader { + slices.Reverse(v) + nv, ok := newHeader[k] + if !ok { + // New doesn't have this header, so we need to add them all + res[k] = []RecipeHeaderStep{RecipeDataHeaderStep(v)} + continue + } + slices.Reverse(nv) + script := myers.Diff(ctx, headerPair{v, nv}) + if script.IsIdentity() { + // Old and new are the same, so we don't record any recipe for them + continue + } + changes := []RecipeHeaderStep{} + + write.Unified(script, os.Stdout, headerPair{v, nv}) + + // Not clear what order the edits come out of myers.Diff, + // so sort by destination range start + slices.SortFunc(script.Ranges, func(a, b edit.Range) int { + return cmp.Compare(a.LowB, b.LowB) + }) + for _, scriptRange := range script.Ranges { + switch { + case scriptRange.IsEqual(): + changes = append(changes, RecipeCopyHeaderStep{ + scriptRange.LowA + 1, + scriptRange.HighA, + }) + case scriptRange.IsInsert(): + changes = append(changes, RecipeDataHeaderStep(v[scriptRange.LowB:scriptRange.HighB])) + } + } + res[k] = changes + } + + for k := range newHeader { + if _, ok := oldHeader[k]; !ok { + // Delete all headers with this name + res[k] = []RecipeHeaderStep{} + } + } + return &res +} +*/ diff --git a/stevea/recipe_body_test.go b/stevea/recipe_body_test.go new file mode 100644 index 0000000..81cab64 --- /dev/null +++ b/stevea/recipe_body_test.go @@ -0,0 +1,74 @@ +package dkim2 + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBodyRecipe(t *testing.T) { + tests := map[string]struct { + New string + Old string + Recipe string + }{ + "body": { + New: "body.new", + Old: "body.old", + Recipe: "body.want.json", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + newBody := forceCRLF(slurpBody(t, tc.New)) + oldBody := forceCRLF(slurpBody(t, tc.Old)) + wantRecipe := string(slurpBody(t, tc.Recipe)) + + recipeBody := diffBody(bytes.NewReader(oldBody), bytes.NewReader(newBody)) + gotB, err := json.Marshal(Recipe{BodyRecipes: recipeBody}) + gotRecipe := string(gotB) + + if err != nil { + t.Fatalf("failed to marshal recipe body: %v", err) + } + //t.Log(gotRecipe) + + //fmt.Printf("newBody=%q\n", string(newBody)) + //fmt.Printf("oldBody=%q\n", string(oldBody)) + + if diff := jsonDiff(wantRecipe, string(gotRecipe)); diff != "" { + t.Errorf("body recipe mismatch (-want +got):\n%s", diff) + } + + recipe := Recipe{ + BodyRecipes: recipeBody, + } + w := bytes.Buffer{} + err = recipe.Body(bytes.NewReader(newBody), &w) + if err != nil { + t.Errorf("failed to apply recipe body: %v", err) + } + got := w.String() + if diff := cmp.Diff(string(oldBody), got); diff != "" { + t.Errorf("body result mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func forceCRLF(b []byte) []byte { + return regexp.MustCompile(`\r?\n`).ReplaceAll(b, []byte("\r\n")) +} + +func slurpBody(t testing.TB, filename string) []byte { + data, err := os.ReadFile(filepath.Join("testdata", "body", filename)) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/stevea/recipe_diff.go b/stevea/recipe_diff.go new file mode 100644 index 0000000..2c06be7 --- /dev/null +++ b/stevea/recipe_diff.go @@ -0,0 +1,143 @@ +package dkim2 + +import ( + "bufio" + "context" + "io" + "slices" + + "cloudeng.io/algo/lcs" +) + +//XXXX Tests for body diff. Create MessageInstance hashes. + +func diffBody(oldBody, newBody io.Reader) *RecipeBodySteps { + var oldLines, newLines []string + + scanner := bufio.NewScanner(oldBody) + for scanner.Scan() { + oldLines = append(oldLines, scanner.Text()) + } + + scanner = bufio.NewScanner(newBody) + for scanner.Scan() { + newLines = append(newLines, scanner.Text()) + } + + // Common case is that they're identical + if slices.Equal(newLines, oldLines) { + return nil + } + + edits := lcs.NewMyers(newLines, oldLines).SES() + var steps RecipeBodySteps + var currentStep RecipeBodyStep + for _, edit := range *edits { + switch edit.Op { + case lcs.Insert: + // Inserting new content + curData, ok := currentStep.(RecipeDataBodyStep) + if ok { + curData = append(curData, edit.Val) + currentStep = curData + continue + } + if currentStep != nil { + steps = append(steps, currentStep) + } + currentStep = RecipeDataBodyStep{edit.Val} + continue + case lcs.Identical: + // Copying content + curCopy, ok := currentStep.(RecipeCopyBodyStep) + if ok && curCopy[1] == edit.A { + curCopy[1] = edit.A + 1 + currentStep = curCopy + continue + } + if currentStep != nil { + steps = append(steps, currentStep) + } + currentStep = RecipeCopyBodyStep{edit.A + 1, edit.A + 1} + continue + } + } + if currentStep != nil { + steps = append(steps, currentStep) + } + return &steps +} + +func diffHeaders(ctx context.Context, oldHeader, newHeader map[string][]string) *RecipeHeaderMap { + if headersEqual(oldHeader, newHeader) { + return nil + } + res := RecipeHeaderMap{} + for headerName, oldValues := range oldHeader { + slices.Reverse(oldValues) + newValues, ok := newHeader[headerName] + if !ok { + // New doesn't have this header, so we need to add them all + res[headerName] = []RecipeHeaderStep{RecipeDataHeaderStep(oldValues)} + continue + } + slices.Reverse(newValues) + if slices.Equal(oldValues, newValues) { + // No change, so we don't need to record any recipe + continue + } + + sourceLoc := map[string]int{} + for i, h := range newValues { + sourceLoc[h] = i + 1 + } + // FIXME(steve): If we have multiple identical headers we'll start copying at the latest + // and our recipe will include more "d" than it needs to. + var steps []RecipeHeaderStep + var currentStep RecipeHeaderStep + var highestSeen int + for _, nh := range oldValues { + i, ok := sourceLoc[nh] + if ok && i > highestSeen { + // We're copying from an existing header + curCopy, ok := currentStep.(RecipeCopyHeaderStep) + if ok && curCopy[1] == i-1 { + curCopy[1] = i + currentStep = curCopy + highestSeen = i + continue + } + if currentStep != nil { + steps = append(steps, currentStep) + } + currentStep = RecipeCopyHeaderStep{i, i} + highestSeen = i + continue + } + // We're putting new content in this header + curData, ok := currentStep.(RecipeDataHeaderStep) + if ok { + curData = append(curData, nh) + currentStep = curData + continue + } + if currentStep != nil { + steps = append(steps, currentStep) + } + currentStep = RecipeDataHeaderStep{nh} + } + if currentStep != nil { + steps = append(steps, currentStep) + } + res[headerName] = steps + } + + // Delete all the headers that are in new but not old + for k := range newHeader { + if _, ok := oldHeader[k]; !ok { + // Delete all headers with this name + res[k] = []RecipeHeaderStep{} + } + } + return &res +} diff --git a/stevea/recipe_header_test.go b/stevea/recipe_header_test.go new file mode 100644 index 0000000..50964f1 --- /dev/null +++ b/stevea/recipe_header_test.go @@ -0,0 +1,117 @@ +package dkim2 + +import ( + "encoding/json" + "maps" + "net/mail" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestRecipe_Header(t *testing.T) { + tests := map[string]struct { + recipe string + input string + want string + wantErr bool + }{ + "no_change": { + "no_change.json", + "no_change.in", + "no_change.want", + false, + }, + "cannot_recreate": { + "cannot_recreate.json", + "cannot_recreate.in", + "cannot_recreate.want", + false, + }, + "preserve_unmentioned": { + "preserve_unmentioned.json", + "preserve_unmentioned.in", + "preserve_unmentioned.want", + false, + }, + "copy_one": { + "copy_one.json", + "copy_one.in", + "copy_one.want", + false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + recipeJson := slurpRecipe(t, tc.recipe) + var discard any + err := json.Unmarshal(recipeJson, &discard) + if err != nil { + t.Fatalf("invalid JSON in recipe: %v", err) + } + _ = discard + + var recipe Recipe + err = json.Unmarshal(recipeJson, &recipe) + if err != nil { + t.Fatalf("failed to unmarshal recipe: %v", err) + } + + input := headerFromString(t, string(slurpRecipe(t, tc.input))) + want := headerFromString(t, string(slurpRecipe(t, tc.want))) + + got, err := recipe.Header(input) + if (err != nil) != tc.wantErr { + t.Errorf("recipe.Header() error = %v, wantErr %v", err, tc.wantErr) + } + + // TODO(steve): use go-cmp + wantString := headerToString(want) + gotString := headerToString(got) + if gotString != wantString { + t.Errorf("recipe.Header() got = %v, want %v", gotString, wantString) + } + }) + } +} + +func headerFromString(t testing.TB, s string) map[string][]string { + if strings.HasPrefix(s, "") { + return nil + } + msg, err := mail.ReadMessage(strings.NewReader(s)) + if err != nil { + t.Fatal(err) + } + ret := make(map[string][]string, len(msg.Header)) + for k, v := range msg.Header { + ret[strings.ToLower(k)] = v + } + return ret +} + +func headerToString(h map[string][]string) string { + if h == nil { + return "\n" + } + var builder strings.Builder + for _, key := range slices.Sorted(maps.Keys(h)) { + for _, value := range h[key] { + builder.WriteString(key) + builder.WriteString(": ") + builder.WriteString(value) + builder.WriteString("\n") + } + } + return builder.String() +} + +func slurpRecipe(t testing.TB, filename string) []byte { + data, err := os.ReadFile(filepath.Join("testdata", "recipe", filename)) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/stevea/recipe_headerdiff_test.go b/stevea/recipe_headerdiff_test.go new file mode 100644 index 0000000..8d6e46e --- /dev/null +++ b/stevea/recipe_headerdiff_test.go @@ -0,0 +1,91 @@ +package dkim2 + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestHeaderDiff(t *testing.T) { + tests := map[string]struct { + Old string + New string + Recipe string + }{ + "reorder": { + "reorder.old", + "reorder.new", + "reorder.want.json", + }, + "deleted": { + "deleted.old", + "deleted.new", + "deleted.want.json", + }, + "ranges": { + "ranges.old", + "ranges.new", + "ranges.want.json", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + oldMail := headerFromString(t, string(slurpDiff(t, tc.Old))) + newMail := headerFromString(t, string(slurpDiff(t, tc.New))) + recipeHeaders := diffHeaders(context.Background(), oldMail, newMail) + + wantRecipe := string(slurpDiff(t, tc.Recipe)) + gotB, err := json.Marshal(recipeHeaders) + gotRecipe := string(gotB) + if err != nil { + t.Fatalf("failed to marshal recipe headers: %v", err) + } + //t.Log(gotRecipe) + + if diff := jsonDiff(wantRecipe, string(gotRecipe)); diff != "" { + t.Errorf("header recipe mismatch (-want +got):\n%s", diff) + } + + // If we apply the recipe to newMail we should get oldMail + recipe := Recipe{ + HeaderRecipes: recipeHeaders, + } + got, err := recipe.Header(newMail) + if err != nil { + t.Errorf("failed to apply recipe header: %v", err) + } + _ = got + }) + } +} + +func slurpDiff(t testing.TB, filename string) []byte { + data, err := os.ReadFile(filepath.Join("testdata", "diff", filename)) + if err != nil { + t.Fatal(err) + } + return data +} + +func jsonDiff(x, y string) string { + xform := cmp.Transformer("JSONcmp", func(s string) (m map[string]interface{}) { + if err := json.Unmarshal([]byte(s), &m); err != nil { + panic(fmt.Sprintf("json.Unmarshal(%s) got unexpected error %#v", s, err)) + } + return m + }) + opt := cmp.FilterPath(func(p cmp.Path) bool { + for _, ps := range p { + if tr, ok := ps.(cmp.Transform); ok && tr.Option() == xform { + return false + } + } + return true + }, xform) + return cmp.Diff(x, y, opt) +} diff --git a/stevea/recipes.schema.json b/stevea/recipes.schema.json new file mode 100644 index 0000000..6749f57 --- /dev/null +++ b/stevea/recipes.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkim2.org/schemas/recipe-v1", + "title": "DKIM2 recipes", + "description": "see draft-dkim-dkim2-spec", + "type": "object", + "properties": { + "h": { + "description": + "recipes to recreate header fields. keys are header field names matched case-insensitively and there MUST NOT be keys that differ only in case" + , + "oneOf": [ + { + "description": "per-field-name recipe arrays", + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/recipe-steps" + } + }, + { + "description": "previous header state cannot be recreated", + "type": "null" + } + ] + }, + "b": { + "description": "recipes to recreate the body", + "oneOf": [ + { + "description": "body recipes", + "$ref": "#/$defs/recipe-steps" + }, + { + "description": "previous body state cannot be recreated", + "type": "null" + }, + { + "description": "body was truncated (DSN)", + "type": "object", + "properties": { + "z": { + "type": "boolean", + "const": true + } + }, + "required": [ + "z" + ], + "additionalProperties": false + } + ] + } + }, + "anyOf": [ + { + "required": [ + "h" + ] + }, + { + "required": [ + "b" + ] + } + ], + "$defs": { + "recipe-steps": { + "type": "array", + "items": { + "oneOf": [ + { + "description": "copy lines/fields, start to end inclusive", + "type": "object", + "properties": { + "c": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "c" + ], + "additionalProperties": false + }, + { + "description": "data lines/values to emit", + "type": "object", + "properties": { + "d": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "d" + ], + "additionalProperties": false + } + ] + } + } + } +} diff --git a/stevea/roundtrip_test.go b/stevea/roundtrip_test.go new file mode 100644 index 0000000..414bf56 --- /dev/null +++ b/stevea/roundtrip_test.go @@ -0,0 +1,181 @@ +package dkim2 + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestRoundTrip(t *testing.T) { + tests := map[string]struct { + Input string + Selector string + Domain string + Key string + MailFrom string + RcptTo []string + }{ + // Test 1: Simple message with Ed25519 + "simple-ed25519": { + Input: "simple.eml", + Selector: "ed25519", + Domain: "test1.dkim2.com", + Key: "ed25519._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 2: Simple message with RSA-1024 + "simple-rsa1024": { + Input: "simple.eml", + Selector: "rsa1024", + Domain: "test1.dkim2.com", + Key: "rsa1024._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 3: Simple message with RSA-2048 (sel1) + "simple-rsa2048": { + Input: "simple.eml", + Selector: "sel1", + Domain: "test1.dkim2.com", + Key: "sel1._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 4: Multi-header message with continuation lines, X- header excluded + "multiheader-ed25519": { + Input: "multiheader.eml", + Selector: "ed25519", + Domain: "test2.dkim2.com", + Key: "ed25519._domainkey.test2.dkim2.com.pem", + MailFrom: "sender@test2.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 5: Message with trailing blank lines (body canonicalization) + "trailingblank-ed25519": { + Input: "trailingblank.eml", + Selector: "ed25519", + Domain: "test3.dkim2.com", + Key: "ed25519._domainkey.test3.dkim2.com.pem", + MailFrom: "sender@test3.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 6: Empty body message + "emptybody-ed25519": { + Input: "emptybody.eml", + Selector: "ed25519", + Domain: "test4.dkim2.com", + Key: "ed25519._domainkey.test4.dkim2.com.pem", + MailFrom: "sender@test4.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 7: Multiple recipients + "multirecipient-ed25519": { + Input: "multirecipient.eml", + Selector: "ed25519", + Domain: "test5.dkim2.com", + Key: "ed25519._domainkey.test5.dkim2.com.pem", + MailFrom: "sender@test5.dkim2.com", + RcptTo: []string{"alice@example.com", "bob@example.com", "charlie@example.com"}, + }, + // Test 8: DSN (empty MAIL FROM) + "dsn-ed25519": { + Input: "simple.eml", + Selector: "ed25519", + Domain: "test1.dkim2.com", + Key: "ed25519._domainkey.test1.dkim2.com.pem", + MailFrom: "<>", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 9: Different selectors on same domain (sel2) + "simple-sel2": { + Input: "simple.eml", + Selector: "sel2", + Domain: "test1.dkim2.com", + Key: "sel2._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 10: Different selectors on same domain (sel3) + "simple-sel3": { + Input: "simple.eml", + Selector: "sel3", + Domain: "test1.dkim2.com", + Key: "sel3._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + // Test 11: Duplicate headers (bottom-up ordering) + "simple-sel4": { + Input: "simple.eml", + Selector: "ed25519", + Domain: "test1.dkim2.com", + Key: "ed25519._domainkey.test1.dkim2.com.pem", + MailFrom: "sender@test1.dkim2.com", + RcptTo: []string{"recipient@example.com"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + input, _ := loadEmail(t, "testdata", "golden", "emails", tc.Input) + key := loadPrivateKey(t, tc.Key) + signOpts := SignOptions{ + Domain: tc.Domain, + Timestamp: 1740000000, + Keys: []SigningKey{ + { + Selector: tc.Selector, + Signer: key, + }, + }, + MailFrom: tc.MailFrom, + RcptTo: tc.RcptTo, + } + var output bytes.Buffer + err := Sign(&output, bytes.NewReader(input), signOpts) + if err != nil { + t.Fatal(err) + } + verifyOpts := VerifyOptions{ + IgnoreTimestamp: true, + Resolver: NewTestResolver(), + MailFrom: tc.MailFrom, + RcptTo: tc.RcptTo, + } + result := Verify(context.Background(), + bytes.NewReader(output.Bytes()), verifyOpts) + if result.State() != StatePass { + t.Errorf("Verify() returned wrong state: %s", result.Err) + t.Logf("Signed mail:\n---\n%s\n---", output.String()) + } + resultAll := VerifyAll(context.Background(), + bytes.NewReader(output.Bytes()), verifyOpts) + if resultAll.State() != StatePass { + t.Errorf("VerifyAll() returned wrong state: %s", resultAll.Err) + } + + goldenFile := filepath.Join("testdata", "golden", "expected", name+".eml") + _, set := os.LookupEnv("GENERATE") + if set { + err = os.WriteFile(goldenFile, output.Bytes(), 0644) + if err != nil { + t.Fatal(err) + } + t.Logf("Written golden file: %s", goldenFile) + } else { + want, err := os.ReadFile(goldenFile) + if err != nil { + t.Skipf("Golden file not present: %v", err) + } + if diff := cmp.Diff(string(want), string(output.Bytes())); diff != "" { + t.Errorf("Golden file mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stevea/sign.go b/stevea/sign.go new file mode 100644 index 0000000..c41ca7b --- /dev/null +++ b/stevea/sign.go @@ -0,0 +1,256 @@ +package dkim2 + +import ( + "bytes" + "crypto" + "crypto/ed25519" + "crypto/rsa" + "fmt" + "io" + "net/mail" +) + +type SigningKey struct { + Selector string + Signer crypto.Signer +} + +type SignOptions struct { + Nonce string + Timestamp int64 + Domain string + Keys []SigningKey + Exploded bool + DoNotExplode bool + DoNotModify bool + Feedback bool + ExtraFlags []string + MailFrom string + RcptTo []string + Modifications *Recipe + Signature *Signature +} + +func Sign(w io.Writer, r io.Reader, opts SignOptions) error { + originalEmail, err := io.ReadAll(r) + if err != nil { + return err + } + msg, err := mail.ReadMessage(bytes.NewReader(originalEmail)) + if err != nil { + return err + } + addedHeaders, err := SignMessage(msg, opts) + if err != nil { + return err + } + for _, h := range addedHeaders { + _, err = io.WriteString(w, h) + if err != nil { + return err + } + } + _, err = w.Write(originalEmail) + if err != nil { + return err + } + return nil +} + +// SignMessage creates DKIM2-Signature and Message-Instance headers +// that can be prepended to the provided message to sign it. +// It reads the body of the message parameter to the end. +func SignMessage(message *mail.Message, + options SignOptions) ([]string, error) { + var addedHeaders []string + + // Get all our existing Message-Instance headers, in ascending order + var existingMiHeaders []*MessageInstance + var err error + miHeaders := message.Header[hdrMessageInstance] + if len(miHeaders) != 0 { + existingMiHeaders, err = MessageInstanceHeaders(miHeaders) + if err != nil { + return nil, err + } + } + + // Calculate existing hash + hashes, err := HashMessage(message.Body, NormalizedHeaders(message.Header)) + if err != nil { + return nil, err + } + + // If we need a new + var newMiHeader *MessageInstance + if len(existingMiHeaders) == 0 { + newMiHeader = &MessageInstance{ + Revision: 1, + Hashes: hashes, + Recipes: options.Modifications, + } + } else { + newestMiHeader := existingMiHeaders[len(existingMiHeaders)-1] + // FIXME(steve): Doesn't handle multiple hash algorithms + if !hashes[0].ContainedBy(newestMiHeader.Hashes) { + if options.Modifications == nil { + return nil, ErrRequiresRecipe{} + } + } + newMiHeader = &MessageInstance{ + Revision: newestMiHeader.Revision + 1, + Hashes: hashes, + Recipes: options.Modifications, + } + } + if newMiHeader != nil { + newMiString, err := newMiHeader.ToString() + if err != nil { + return nil, err + } + addedHeaders = append(addedHeaders, hdrMessageInstance+": "+newMiString+"\r\n") + miHeaders = append(miHeaders, newMiString) + existingMiHeaders = append(existingMiHeaders, newMiHeader) + } + + // Create the DKIM2-Signature header we're going to + // add signatures to. + newSignatureHeader := options.Signature + if newSignatureHeader == nil { + var sigs []Sig + for _, key := range options.Keys { + var name string + switch key.Signer.Public().(type) { + case *rsa.PublicKey: + name = "rsa-sha256" + case ed25519.PublicKey: + name = "ed25519-sha256" + default: + panic(fmt.Errorf("unsupported signing algorithm: %T", key)) + } + sigs = append(sigs, + Sig{ + Selector: key.Selector, + Name: name, + }) + } + newSignatureHeader = &Signature{ + Sequence: 0, + MIRevision: 0, + Nonce: options.Nonce, + Timestamp: options.Timestamp, + MailFrom: options.MailFrom, + RcptTo: options.RcptTo, + Domain: options.Domain, + Signatures: sigs, + Exploded: options.Exploded, + DoNotExplode: options.DoNotExplode, + DoNotModify: options.DoNotModify, + Feedback: options.Feedback, + ExtraFlags: nil, + } + } + + if newSignatureHeader.MIRevision == 0 { + newestMiHeader := existingMiHeaders[len(existingMiHeaders)-1] + newSignatureHeader.MIRevision = newestMiHeader.Revision + } + + if newSignatureHeader.Sequence == 0 { + d2Headers, ok := message.Header[hdrDKIM2Signature] + if !ok { + newSignatureHeader.Sequence = 1 + } else { + sortedD2Headers, err := Dkim2SignatureHeaders(d2Headers) + if err != nil { + return nil, err + } + newSignatureHeader.Sequence = sortedD2Headers[len(sortedD2Headers)-1].Sequence + 1 + } + } + + if newSignatureHeader.Timestamp == 0 { + newSignatureHeader.Timestamp = Now() + } + + headersToSign, err := newSignatureHeader.ConcatHeadersForSigning(mail.Header{ + hdrDKIM2Signature: message.Header[hdrDKIM2Signature], + hdrMessageInstance: miHeaders, + }) + if err != nil { + return nil, err + } + newSignatureHeader.Signatures = nil + for _, signer := range options.Keys { + err = newSignatureHeader.AddSignature(signer.Selector, signer.Signer, headersToSign) + if err != nil { + return nil, err + } + } + addedHeaders = append(addedHeaders, hdrDKIM2Signature+": "+newSignatureHeader.ToString()+"\r\n") + return addedHeaders, nil +} + +/* +# Signer Actions + +This section gives the actions that need to be undertaken by the signer +of a message. They may be done in any appropriate order. + +## Add any Necessary Message-Instance Header Fields + +If a system is generating the initial form of a message or if +it is a Reviser that has made changes to the message body and/or +header fields then it MUST compute the body hash as described in +{{computing-body-hash}} and the hash of the header fields +as described in {{computing-header-hash}}. + +If the message does not contain a Message-Instance header field then one +MUST be added. + +If hashing the message body or relevant header fields does not +give the same hash values as those recorded in the highest version +(m=) Message-Instance header field then a new Message-Instance +header field MUST be added and if they are the same a new +Message-Instance header field SHOULD NOT be added. + +A Message-Instance header field MUST contain "recipes" to be able to +recreate the message corresponding to the hash values in the +currently highest numbered Message-Instance header field, or a +null recipe to indicate that recreating the previous version +of the message will not be possible. + +A system may add more than one Message-Instance header field if it +wishes to do so, but the DKIM2 design allows all modifications made by +any single system to be documented +in a single Message-Instance header field. + +Note that the first (m=1) Message-Instance header field MAY +contain "recipes" if it is wished to record any changes made to a +message as it enters the DKIM2 ecosystem. All other Message-Instance +header fields SHOULD contain at least one "recipe". + +## Provide a "Chain of Custody" for the Message {#chain-of-custody} + +The DKIM2-Signature header field contains the MAIL FROM +and RCPT TO values that will be used when the message is transmitted, +so these [RFC5321] "envelope" values MUST be available to (or +deducible by) a Signer. + +The receiver of a message will check for an exact match (including +the local parts of the email addresses) between the MAIL FROM / RCPT TO +[RFC5321] protocol values and the mf= and rt= values in the highest numbered +(most recent) DKIM2-Signature header field. It is acceptable for there to +be more RCPT TO email addresses recorded in rt= than are actually used in +the SMTP conversation, but any RCPT TO value which is used MUST be present. + +Verifiers will check for a relaxed domain match (see {{relaxed-domain-match}}) +between the signing domain (d=) and the domain in the MAIL FROM value. + +When the message being signed already has a DKIM2-Signature header field +(i.e. it has already been transmitted at least once) then a valid +"chain of custody" MUST be apparent when all of the DKIM2-Signature header fields +are considered. This "chain of custody" contributes to the way in +which DKIM2 tackles "DKIM replay" attacks. + +*/ diff --git a/stevea/spec/draft-ietf-dkim-dkim2-motivation-02.txt b/stevea/spec/draft-ietf-dkim-dkim2-motivation-02.txt new file mode 100644 index 0000000..609fea9 --- /dev/null +++ b/stevea/spec/draft-ietf-dkim-dkim2-motivation-02.txt @@ -0,0 +1,616 @@ + + + + +Network Working Group B. Gondwana +Internet-Draft Fastmail +Intended status: Standards Track R. Clayton +Expires: 7 May 2026 Yahoo + W. Chuang + Google + 3 November 2025 + + + DKIM2 - signing the source and destination of every email + draft-ietf-dkim-dkim2-motivation-02 + +Abstract + + This memo provides a rationale for building a new email + accountability mechanism, based on the lessons learned from + implementing the ARC experiment from RFC 8617 and other experiences + from email system operators. + +Status of This Memo + + This Internet-Draft is submitted in full conformance with the + provisions of BCP 78 and BCP 79. + + Internet-Drafts are working documents of the Internet Engineering + Task Force (IETF). Note that other groups may also distribute + working documents as Internet-Drafts. The list of current Internet- + Drafts is at https://datatracker.ietf.org/drafts/current/. + + Internet-Drafts are draft documents valid for a maximum of six months + and may be updated, replaced, or obsoleted by other documents at any + time. It is inappropriate to use Internet-Drafts as reference + material or to cite them other than as "work in progress." + + This Internet-Draft will expire on 7 May 2026. + +Copyright Notice + + Copyright (c) 2025 IETF Trust and the persons identified as the + document authors. All rights reserved. + + + + + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 1] + +Internet-Draft DKIM2 Motivation November 2025 + + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents (https://trustee.ietf.org/ + license-info) in effect on the date of publication of this document. + Please review these documents carefully, as they describe your rights + and restrictions with respect to this document. Code Components + extracted from this document must include Revised BSD License text as + described in Section 4.e of the Trust Legal Provisions and are + provided without warranty as described in the Revised BSD License. + +Table of Contents + + 1. Background and motivations . . . . . . . . . . . . . . . . . 3 + 2. Some properties for a system which would solve these + issues . . . . . . . . . . . . . . . . . . . . . . . . . 3 + 2.1. Explicit signing of all legitimate recipients for each + message . . . . . . . . . . . . . . . . . . . . . . . . . 4 + 2.2. A chain of aligned signatures over multiple SMTP + transactions . . . . . . . . . . . . . . . . . . . . . . 4 + 2.3. A signed bounce format, sent in reverse along the same + path . . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 2.4. A way to describe changes . . . . . . . . . . . . . . . . 5 + 2.4.1. Security gateways . . . . . . . . . . . . . . . . . . 6 + 3. Goals to be addressed . . . . . . . . . . . . . . . . . . . . 6 + 3.1. DKIM-replay . . . . . . . . . . . . . . . . . . . . . . . 6 + 3.2. Backscatter . . . . . . . . . . . . . . . . . . . . . . . 7 + 4. Other areas of interest . . . . . . . . . . . . . . . . . . . 8 + 4.1. Algorithmic dexterity . . . . . . . . . . . . . . . . . . 8 + 4.2. Sender indications of intent . . . . . . . . . . . . . . 9 + 4.3. Signer requests for feedback . . . . . . . . . . . . . . 9 + 4.4. Simplification of signed header list . . . . . . . . . . 9 + 5. Security . . . . . . . . . . . . . . . . . . . . . . . . . . 9 + 6. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 9 + 7. Normative References . . . . . . . . . . . . . . . . . . . . 9 + Appendix A. Changes from Earlier Versions . . . . . . . . . . . 10 + A.1. draft-ietf-dkim-dkim2-motivation-02: . . . . . . . . . . 10 + A.2. draft-ietf-dkim-dkim2-motivation-01: . . . . . . . . . . 10 + A.3. draft-ietf-dkim-dkim2-motivation-00: . . . . . . . . . . 10 + A.4. draft-gondwana-dkim2-motivation-03: . . . . . . . . . . . 10 + A.5. draft-gondwana-dkim2-motivation-02: . . . . . . . . . . . 11 + A.6. draft-gondwana-dkim2-motivation-01: . . . . . . . . . . . 11 + A.7. draft-gondwana-dkim2-motivation-00: . . . . . . . . . . . 11 + Authors' Addresses . . . . . . . . . . . . . . . . . . . . . . . 11 + + + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 2] + +Internet-Draft DKIM2 Motivation November 2025 + + +1. Background and motivations + + In 2007, [DKIM] (Domain Key Identified Mail / DKIM) was published, + outlining a mechanism for a domain to sign email in a way that + recipients could ensure that the email had come from an entity + possessing the secret key matching a public key published in the DNS + by the source domain. + + [DKIM] has been updated and extended many times since then, and a + large amount of operational experience has been gained using it. + + There are a number of things beyond authenticating the original email + that would be useful for mail system operators, particularly when it + travels through multiple hops. There have been other attempts to + solve some of these problems, e.g. [ARC] (Authenticated Received + Chain / ARC), however they have not achieved the same level of + widespread use as DKIM. + + In particular, the following issues frustrate email system system + operators: + + 1. You can legitimately receive a validly DKIM signed email, where + there is no evidence inside the signed part that you were an + intended recipient + + 2. An email can have a bounce (SMTP-FROM) email address for a domain + which was never involved in the transit of that message + + 3. An email can be altered by forwarders or mailing lists, and + there’s no way to know what parts of the message were changed + + In the first two cases, a solution would be to have the sending + system provide an unforgeable digital signature describing its + identity and intent, such that if all parties involved in transiting + versions of an email participate, there is no way for a third party + to pretend to have been legitimately involved in processing the + email, or to change or redirect the email in any way. + + In the third case, a solution would be to provide a way to describe + how to undo the changes, such that each domain's signature can be + attributed to the version of the message that was seen by systems + which can sign on behalf of that domain. + +2. Some properties for a system which would solve these issues + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 3] + +Internet-Draft DKIM2 Motivation November 2025 + + +2.1. Explicit signing of all legitimate recipients for each message + + By ensuring that the complete list of legitimate recipients for a + message is encoded in the signed content of the message, it will + become possible for receiving systems to confirm that they are an + intended next hop for a message, and reject messages which the signer + did not intend for them to receive. + + Even if a message is BCC'd, a copy of that message sent to the BCC + recipient can have that recipient address mentioned, without sending + the same exact copy to the other recipients. + + This mechanism does not survive naive forwarders, where the new + destination address will not be explicitly mentioned, however a + recipient system can track which addresses forward to it, and accept + just those. + + Over time as more software is updated to add signatures, the need to + use heuristics becomes smaller, and eventually it will become + possible to reject any messages where the [SMTP] RCPT TO forward-path + addresses are not all present in the highest signature number header. + +2.2. A chain of aligned signatures over multiple SMTP transactions + + By having the initial signature be from the domain aligned to the + From or Sender header, and each following hop adding its own + signature with the domain of the recipient of the previous hop, it is + possible to create a chain of custody where each recipient has + confirmed that it should have received the message, and then signed + the content with a key for its own domain. + + If the recipient wishes to forward the message on to another address, + it must apply its own DKIM2 header, signed by a key which is aligned + to the domain of the recipient address in the previous DKIM2 header, + and with a bounce address which is in the same domain. + + The end result is, like ARC, a chain of domains which have handled + the message; however unlike ARC, this chain MUST be fully linked in + both directions, with every sending address aligning to the recipient + address of the previous DKIM2 header. + + + + + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 4] + +Internet-Draft DKIM2 Motivation November 2025 + + +2.3. A signed bounce format, sent in reverse along the same path + + By having the mail-from address be signed and aligned to the signing + domain, and having the bounce format include the signature headers of + the message being bounced, it is required to have directly received + the message to generate a bounce for it. This requirement eliminates + the ability to cause backscatter entirely, as bounces can only go to + a domain that sent the message, and only be sent from a domain which + explicitly received that message. + + The ability to avoid backscatter will allow receiving systems to + delay their decisions about whether to accept a message, since they + can make the decision without holding the connection open. This + removes the need for mitigations like greylisting and even reduces + the need for junk mail folders in jurisdictions where it is forbidden + to discard messages once they are accepted. + + Since the DSN messages always go back up the DKIM2 chain, any hop can + strip off the higher number (i=) records; including the sender and + recipient addresses for them, and create a bounce as if the forwarder + itself was doing the rejection. + + This would not be possible with SMTP-transaction-time rejection, as + you can't reliably hold open the connection from the previous hop + while you talk to the next hop. + + As asynchronous bounces will be common in DKIM2, this case becomes + indistinguishable to the sender, allowing privacy-preserving + forwarders to seamlessly operate. + + Passing bounces back along the outgoing path also allows mailing + lists to take responsibility for the event and not bother the person + who sent a message to the list. + + Provided that an email is correctly signed when received, it can be + rejected at a later point in time. The DSN will be sent to the + immediately preceding intermediary. Since the bounce travels back + along the (fully authenticated) incoming path it cannot be sent to an + uninvolved third party. + +2.4. A way to describe changes + + ARC describes a separate "Seal" header which which never gets + modified, however this still allows an intermediate to make massive + changes to a message and claim that it was still the original + message. If a message goes through more than one set of + modifications, it becomes impossible for the receiver to know what + changes were made by each intermediary. + + + +Gondwana, et al. Expires 7 May 2026 [Page 5] + +Internet-Draft DKIM2 Motivation November 2025 + + + By defining an algebra sufficient to describe how to undo common + changes, we can allow the receiver to compare the eventual message + received with the original message sent, and decide which parties + involved in changing the message are making the kind of changes that + the recipient doesn't want. + + Mailing lists (or alumni forwarders etc.) that alter the Subject + header field (or other [IMF] headers) will record the previous header + field contents. This is easy to undo for checking purposes. + + Mailing lists that add text (either to a simple email body or one or + more MIME parts within the body) will record details of the text they + have added. This text can then be removed when checking earlier + signatures. + +2.4.1. Security gateways + + There are some types of alteration, for example by security gateways, + that may be impractical to describe in a cost-effective manner. + + We would expect that outgoing gateways that may be adding disclaimers + or rewriting internal identifiers would be provided with appropriate + signing keys so that they could be the "first hop" as far as the rest + of the email handling chain is concerned. + + Incoming security gateways may be making substantial changes. + Typically they will remove problematic types of attachment and + rewrite URLs to use "interstitials". + + Since this type of functionality is generally provided on a + contracted basis, further intermediaries will be fully aware of the + presence of the security gateway and can be configured to implicitly + trust that it has checked earlier signatures and found them to be + correct. Hence there is no need to be able to "undo" these changes, + however there's still value in indicating which system made these + changes. + +3. Goals to be addressed + +3.1. DKIM-replay + + Because an email can currently be sent as "Bcc" such that there's no + evidence in the message data of who the recipient is expected to be, + it's possible to take a message that is correctly signed and replay + it millions of times to different destination addresses as if they + had been BCC'd. This message can be resent at any time. + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 6] + +Internet-Draft DKIM2 Motivation November 2025 + + + DKIM2 headers will always have timestamps so that "old" signatures + have no value. + + A possibility to be investigated during testing is a "singleton" flag + to allow senders to specify that this is a message for a single + recipient (e.g. for authentication codes for billing transactions) + and should not be expanded by mailing lists. + + DKIM2 headers specify both "from" and "to" so that most opportunities + to alter a message, re-sign it and replay it at scale will no longer + be possible. Since the "to" address is always encoded in the email, + any email to multiple recipients must be exploded by the sender, and + each copy signed separately with different headers. + + If the email is replayed (perhaps through a large system with many + different customers) then if the email does not say that it has been + duplicated then signatures can be assumed to be unique and hence + simple caching (or Bloom filters) will identify replays. If the + email has been duplicated then recipients can assign a reputation to + the entity that did the duplication (along with the expected number + of duplicates that will arrive from that entity) and assess duplicate + signatures on that basis. + + If the email is altered before duplication then it is again the case + that this will be apparent to the recipient who can develop a + reputation system for the entity that did the modification and + replay. + +3.2. Backscatter + + The problem of backscatter, delivery status notifications sent to + innocent third parties who had their address forged as the source of + a message, has caused email recipients to implement a variety of + countermeasures: + + * in-band scanning: performing detailed analysis of the email + content before replying to the DATA phase of the SMTP transaction, + allowing immediate rejection but consuming resources on both ends + of the connection, and limiting the time that can be used for the + analysis to avoid timeouts. + + * greylisting: replying with a temporary failure code to untrusted + senders, allowing time to decide if the sender is trustworthy + enough, but also delaying mail for an indeterminate period. + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 7] + +Internet-Draft DKIM2 Motivation November 2025 + + + * delivery to "Spam" or "Junk" mailboxes - in some jurisdictions + it's not allowed to discard email that has been accepted, so + providers must put the copy somewhere once they have accepted it, + filling Junk mailboxes even if they're very sure it's bad. + + By requiring bounce addresses to aligned with the most recent + signature domain, we can avoid backscatter, allowing recipients to + always take the message, and later return a bounce. This fulfils any + legal obligation to inform the sender if the message isn't delivered, + while also avoiding the timeout and greylisting re-connection issue + that currently exists, so messages are spooled for less time on + intermediates, and recipients can take their time to analyse + messages; even delivering the message to a mailbox and then upon + receiving further intelligence, undoing the delivery and generating a + bounce. + + Privacy-preserving forwarding services will also see every bounce + from any dkim2-supporting destination mailbox, allowing them to strip + off the details of the further hop(s) and generate a bounce as if + they had been the terminal node of the delivery and were just making + a delayed decision. + +4. Other areas of interest + +4.1. Algorithmic dexterity + + The final specification will require both RSA and elliptic curve be + implemented for algorithmic agility. However this document + acknowledges the long standing lack of adoption of elliptic curve + ,and elliptic curve support may not be needed for development. The + specifications will provide support for multiple algorithms. If + there is IETF consensus around a "post-quantum" scheme then that will + also be included. + + Dexterity will become essential if advances in cryptanalysis cause a + particular type of algorithm to become deprecated. To allow a phased + switch away from such an algorithm we will make provision for more + than one signature to be present in a single DKIM2 header. Systems + capable of checking both signatures will require both to be correct. + If only one signature is correct then email will be rejected with a + clear message -- allowing interworking issues to be easily debugged. + + To allow for future dexterity, it makes sense to allow multiple + signatures with the same or different algorithms, from the same + domain, on the same message. + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 8] + +Internet-Draft DKIM2 Motivation November 2025 + + +4.2. Sender indications of intent + + Having a way to indicate "the sender wants you not to make any + modifications to this message" will allow senders to indicate the + same intent they current achieve with a DMARC p=reject policy to stop + messages which don't have a verifying DKIM signature. + + Having a way to indicate "this message is for a single recipient" has + been requested by some services like document signature services. + + Having a way to indicate "this message will be useless after time X" + will be useful for things like confirmation codes which have limited + validity, allowing intermediate systems to return the message if they + haven't been able to complete delivery by the expiry time. + +4.3. Signer requests for feedback + + Each signer on the chain may wish to receive feedback about messages, + in the way that they currently use multiple DKIM signatures along + with DMARC policies. + + We can add a flag to allow intermediate signers (email sending + providers, mailing lists, forwarders, etc) to say whether they wish + to receive feedback about each message that they sign. + +4.4. Simplification of signed header list + + Currently DKIM signatures list a particular number of copies of each + header field which are included in the signature, and the signer can + choose exactly which headers to sign. + + It is both valuable to mandate a set of headers, and the existence of + a change algebra will allow us to insist that all copies of a named + header field are always signed, reducing the risk of header stuffing + attacks. + +5. Security + + TBA + +6. IANA Considerations + + TBA + +7. Normative References + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 9] + +Internet-Draft DKIM2 Motivation November 2025 + + + [ARC] Andersen, K., Long, B., Ed., Blank, S., Ed., and M. + Kucherawy, Ed., "The Authenticated Received Chain (ARC) + Protocol", RFC 8617, DOI 10.17487/RFC8617, July 2019, + . + + [DKIM] Crocker, D., Ed., Hansen, T., Ed., and M. Kucherawy, Ed., + "DomainKeys Identified Mail (DKIM) Signatures", STD 76, + RFC 6376, DOI 10.17487/RFC6376, September 2011, + . + + [IMF] Resnick, P., Ed., "Internet Message Format", RFC 5322, + DOI 10.17487/RFC5322, October 2008, + . + + [SMTP] Klensin, J., "Simple Mail Transfer Protocol", RFC 5321, + DOI 10.17487/RFC5321, October 2008, + . + +Appendix A. Changes from Earlier Versions + + [[This section to be removed by RFC Editor]] + +A.1. draft-ietf-dkim-dkim2-motivation-02: + + * Updated the background and motivations to be more about the + problems + + * Moved "simplification of signed header list" into the "other + areas", it's not core to solving the underlying problems. + +A.2. draft-ietf-dkim-dkim2-motivation-01: + + * saying DKIM1 is silly, just calling it DKIM + + * updated DKIM reference to RFC6376 + + * use named references + +A.3. draft-ietf-dkim-dkim2-motivation-00: + + * no changes other than the name + +A.4. draft-gondwana-dkim2-motivation-03: + + * typo fixes + + * updated title + + + + +Gondwana, et al. Expires 7 May 2026 [Page 10] + +Internet-Draft DKIM2 Motivation November 2025 + + + * allowed for multiple recipients to be signed (but still all + legtimiate recipients MUST be explicitly signed) + + * rewrote to be more "motivation/goals" and less "implementation + design" + + * removed the 'obsoletes' + +A.5. draft-gondwana-dkim2-motivation-02: + + * changed section title because DKIM1/2 do not really interwork as + such + + * removed implementation details, this is the motivation doc + + * significant rewrite based on feedback from mailing list + +A.6. draft-gondwana-dkim2-motivation-01: + + * remove the z= parameter on the grounds that it adds too much + complexity + + * document that messages MUST NOT re-enter the DKIM2 world once the + chain has been broken. + +A.7. draft-gondwana-dkim2-motivation-00: + + * initial version + +Authors' Addresses + + Bron Gondwana + Fastmail + Email: brong@fastmailteam.com + + + Richard Clayton + Yahoo + Email: rclayton@yahooinc.com + + + Wei Chuang + Google + Email: weihaw@google.com + + + + + + + +Gondwana, et al. Expires 7 May 2026 [Page 11] diff --git a/stevea/spec/draft-ietf-dkim-dkim2-spec-01.txt b/stevea/spec/draft-ietf-dkim-dkim2-spec-01.txt new file mode 100644 index 0000000..ef3be04 --- /dev/null +++ b/stevea/spec/draft-ietf-dkim-dkim2-spec-01.txt @@ -0,0 +1,2184 @@ + + + + +Network Working Group R. Clayton +Internet-Draft Yahoo +Intended status: Standards Track W. Chuang +Expires: 22 October 2026 Google + B. Gondwana + Fastmail Pty Ltd + 20 April 2026 + + + DomainKeys Identified Mail Signatures v2 (DKIM2) + draft-ietf-dkim-dkim2-spec-01 + +Abstract + + DomainKeys Identified Mail v2 (DKIM2) permits a person, role, or + organization that owns a signing domain to document that it has + handled an email message by associating their domain with the + message. This is achieved by providing a hash value that has been + calculated on the current contents of the message and then applying a + cryptographic signature that covers the hash values and other details + about the transmission of the message. Verification is performed by + querying an entry within the signing domain's DNS space to retrieve + an appropriate public key. As a message is transferred from author + to recipient systems that alter the body or header fields will + provide details of their changes and calculate new hash values. + Further signatures will be added to provide a validatable "chain". + This permits validators to identify the nature of changes made by + intermediaries and apply a reputation to the systems that made + changed. DKIM2 also allows recipients to detect when messages have + been unexpectedly "replayed" and will ensure that Delivery Status + Notifications are only sent to entities that were involved in the + transmission of a message. + +Status of This Memo + + This Internet-Draft is submitted in full conformance with the + provisions of BCP 78 and BCP 79. + + Internet-Drafts are working documents of the Internet Engineering + Task Force (IETF). Note that other groups may also distribute + working documents as Internet-Drafts. The list of current Internet- + Drafts is at https://datatracker.ietf.org/drafts/current/. + + Internet-Drafts are draft documents valid for a maximum of six months + and may be updated, replaced, or obsoleted by other documents at any + time. It is inappropriate to use Internet-Drafts as reference + material or to cite them other than as "work in progress." + + + + +Clayton, et al. Expires 22 October 2026 [Page 1] + +Internet-Draft DKIM2 Signatures April 2026 + + + This Internet-Draft will expire on 22 October 2026. + +Copyright Notice + + Copyright (c) 2026 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents (https://trustee.ietf.org/ + license-info) in effect on the date of publication of this document. + Please review these documents carefully, as they describe your rights + and restrictions with respect to this document. Code Components + extracted from this document must include Revised BSD License text as + described in Section 4.e of the Trust Legal Provisions and are + provided without warranty as described in the Revised BSD License. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . 4 + 1.1. DKIM2 Architecture Documents . . . . . . . . . . . . . . 4 + 2. Terminology and Definitions . . . . . . . . . . . . . . . . . 4 + 2.1. Signer . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 2.2. Forwarder . . . . . . . . . . . . . . . . . . . . . . . . 5 + 2.3. Reviser . . . . . . . . . . . . . . . . . . . . . . . . . 6 + 2.4. Verifier . . . . . . . . . . . . . . . . . . . . . . . . 6 + 2.5. Signing Domain . . . . . . . . . . . . . . . . . . . . . 6 + 2.6. Originator . . . . . . . . . . . . . . . . . . . . . . . 6 + 2.7. Header Field . . . . . . . . . . . . . . . . . . . . . . 6 + 2.8. Tag . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 + 2.9. Message Body . . . . . . . . . . . . . . . . . . . . . . 7 + 2.10. Hash . . . . . . . . . . . . . . . . . . . . . . . . . . 7 + 2.11. Glossary . . . . . . . . . . . . . . . . . . . . . . . . 7 + 2.12. Whitespace . . . . . . . . . . . . . . . . . . . . . . . 8 + 2.13. Imported ABNF Tokens . . . . . . . . . . . . . . . . . . 8 + 2.14. Common ABNF Tokens . . . . . . . . . . . . . . . . . . . 8 + 3. Signing and Verification Cryptographic Algorithms . . . . . . 9 + 3.1. The SHA256 Hashing Algorithm . . . . . . . . . . . . . . 9 + 3.2. The RSA-SHA256 Signing Algorithm . . . . . . . . . . . . 9 + 3.3. The Ed25519-SHA256 Signing Algorithm . . . . . . . . . . 10 + 3.4. Other Algorithms . . . . . . . . . . . . . . . . . . . . 10 + 3.5. Selectors . . . . . . . . . . . . . . . . . . . . . . . . 10 + 3.6. Key Management . . . . . . . . . . . . . . . . . . . . . 11 + 4. Recipes . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 + 4.1. Header Recipes . . . . . . . . . . . . . . . . . . . . . 13 + 4.2. Body Recipes . . . . . . . . . . . . . . . . . . . . . . 14 + 5. Message Hash Values . . . . . . . . . . . . . . . . . . . . . 15 + 5.1. Computing the Body Hash . . . . . . . . . . . . . . . . . 15 + 5.2. Computing the Header Fields Hash . . . . . . . . . . . . 16 + + + +Clayton, et al. Expires 22 October 2026 [Page 2] + +Internet-Draft DKIM2 Signatures April 2026 + + + 6. The Message-Instance Header Field . . . . . . . . . . . . . . 17 + 6.1. m= the revision number of the Message-Instance header + field . . . . . . . . . . . . . . . . . . . . . . . . . . 18 + 6.2. r= recipes to recreate the previous instance of the + message . . . . . . . . . . . . . . . . . . . . . . . . . 18 + 6.3. h= the hash values for the message . . . . . . . . . . . 18 + 7. The DKIM2-Signature Header Field . . . . . . . . . . . . . . 19 + 7.1. i= the sequence number of the DKIM2-Signature header + field . . . . . . . . . . . . . . . . . . . . . . . . . . 19 + 7.2. m= the highest numbered Message-Instance header field . . 20 + 7.3. n= nonce value . . . . . . . . . . . . . . . . . . . . . 20 + 7.4. t= signature timestamp . . . . . . . . . . . . . . . . . 20 + 7.5. mf= the MAIL FROM used when the message was sent . . . . 21 + 7.6. rt= the RCPT TO value(s) used when the message was + sent . . . . . . . . . . . . . . . . . . . . . . . . . . 21 + 7.7. d= the domain associated with this signature. . . . . . . 22 + 7.8. s= the signature value(s) for the message . . . . . . . . 22 + 7.9. f= flags . . . . . . . . . . . . . . . . . . . . . . . . 23 + 8. Signer Actions . . . . . . . . . . . . . . . . . . . . . . . 24 + 8.1. Add any Necessary Message-Instance Header Fields . . . . 24 + 8.2. Provide a "Chain of Custody" for the Message . . . . . . 24 + 8.3. The Relaxed Domain Match Algorithm . . . . . . . . . . . 25 + 8.4. Select a Private Key and Corresponding Selector Value . . 26 + 8.5. Calculate a Signature Value . . . . . . . . . . . . . . . 26 + 9. Verification Requirements . . . . . . . . . . . . . . . . . . 27 + 9.1. Check Most Recent Signature and Hashes for the Message . 27 + 9.2. Checking the Message-Instance Header Fields . . . . . . . 28 + 9.3. Checking the DKIM2-Signature Header Fields . . . . . . . 28 + 9.4. Interpret Results/Apply Local Policy . . . . . . . . . . 28 + 10. Verifier Actions . . . . . . . . . . . . . . . . . . . . . . 29 + 10.1. Output States . . . . . . . . . . . . . . . . . . . . . 29 + 10.2. Ensure that the DKIM2 Header Fields are Valid . . . . . 30 + 10.3. Check the timestamps . . . . . . . . . . . . . . . . . . 31 + 10.4. Check the Chain-of-Custody . . . . . . . . . . . . . . . 31 + 10.5. Fetch the Public Key . . . . . . . . . . . . . . . . . . 32 + 10.6. Perform the Signature Verification Calculation . . . . . 32 + 10.7. Validating Body and Header hashes . . . . . . . . . . . 33 + 11. Delivery Status Notifications in the DKIM2 ecosystem . . . . 33 + 11.1. DSN contents . . . . . . . . . . . . . . . . . . . . . . 34 + 11.1.1. Bounce Propagation . . . . . . . . . . . . . . . . . 34 + 11.1.2. Authentication of Inbound Bounce Notifications . . . 34 + 12. Preventing Transport Conversions . . . . . . . . . . . . . . 35 + 13. EAI (RFC6530) Considerations for DKIM2 . . . . . . . . . . . 36 + 14. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 36 + 15. Security Considerations . . . . . . . . . . . . . . . . . . . 36 + 16. Changes from Earlier Versions . . . . . . . . . . . . . . . . 36 + 17. References . . . . . . . . . . . . . . . . . . . . . . . . . 37 + 17.1. Normative References . . . . . . . . . . . . . . . . . . 37 + + + +Clayton, et al. Expires 22 October 2026 [Page 3] + +Internet-Draft DKIM2 Signatures April 2026 + + + 17.2. Informative References . . . . . . . . . . . . . . . . . 38 + Authors' Addresses . . . . . . . . . . . . . . . . . . . . . . . 39 + +1. Introduction + + DomainKeys Identified Mail v2 (DKIM2) permits a person, role, or + organization to document that they have handled an email message by + associating a domain name [RFC1034] with the message [RFC5322]. A + public key signature is used to record that they have been able to + read the contents of the message and write to it. + + Verification of claims is achieved by fetching a public key stored in + the DNS under the relevant domain and then checking the signature. + + Message transit from author to recipient is through Forwarders that + typically make no substantive change to the message content and thus + preserve the DKIM2 signature. Where they do make a change the + changes they have made are documented so that these can be "undone" + and the original signature validated. + + When a message is forwarded from one system to another an additional + DKIM2 signature is added on each occasion. This chain of custody + assists validators in distinguishing between messages that were + intended to be sent to a particular email address and those that are + being "replayed" to that address. + + The chain of custody can also be used to ensure that delivery status + notifications are only sent to entities that were involved in the + transmission of a message. + + Organizations that process a message can add to their signature a + request for feedback as to any opinion (for example, that the email + was considered to be spam) that the eventual recipient of the message + wishes to share. + +1.1. DKIM2 Architecture Documents + + Readers are advised to be familiar with the material in TBA, TBA and + TBA which provide the background for the development of DKIM2, an + overview of the service, and deployment and operations guidance and + advice. + +2. Terminology and Definitions + + This section defines terms used in the rest of the document. + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 4] + +Internet-Draft DKIM2 Signatures April 2026 + + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in + [RFC2119]. These words take their normative meanings only when they + are presented in ALL UPPERCASE. + + DKIM2 is designed to operate within the Internet Mail service, as + defined in [RFC5598]. Basic email terminology is taken from that + specification. + + DKIM2 inherits many ideas from DKIM ([RFC6376]) which, for clarity we + refer to in this specification as DKIM1. In addition, some features + were influenced by experience with (see [CONCLUDEARC]) the + experimental ARC protocol ([RFC8617]). + + Syntax descriptions use Augmented BNF (ABNF) [RFC5234]. + + This document uses JSON [RFC8259] to encode the "recipes" which + record changes made to a message header fields or body. The JSON + objects are then base64 encoded. This means that a standard JSON + parser can be used to create what may be quite complex data + structures. Unrecognised fields within JSON objects MUST be ignored. + +2.1. Signer + + Elements in the mail system that sign messages on behalf of a domain + are referred to as Signers. These may be MUAs (Mail User Agents), + MSAs (Mail Submission Agents), MTAs (Mail Transfer Agents), or other + agents such as mailing list "exploders". In general, any Signer will + be involved in the injection of a message into the message system in + some way. The key point is that a message must be signed before it + leaves the administrative domain of the Signer. + +2.2. Forwarder + + [RFC5598] defines a Relay as transmitting or retransmitting a message + but states that it will not modify the envelope information or the + message content semantics. It also defines a Gateway as a hybrid of + User and Relay that connects heterogeneous mail services. In this + document we use the concept of a Forwarder which is an MTA that + receives a message and then, as an alternative to delivering it into + a destination mailbox, can forward it on to another system in an + automated, pre-determined, manner. + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 5] + +Internet-Draft DKIM2 Signatures April 2026 + + +2.3. Reviser + + As will be seen, a Forwarder may alter the message content or header + fields, in such a way that existing signatures on the message will no + longer validate. If so, then a record will be made of these changes. + We call a Forwarder that makes such changes a Reviser. + +2.4. Verifier + + Elements in the mail system that verify signatures are referred to as + Verifiers. These may be Forwarders, Revisers, MTAs, Mail Delivery + Agents (MDAs), or MUAs. It is an expectation of DKIM2 that a + recipient of a message will wish to verify some or all signatures + before determining whether or not to accept the message or pass it on + to another entity. + +2.5. Signing Domain + + A domain name associated with a signature. This domain may be + associated with the author of an email, their organization, a company + hired to deliver the email, a mailing list operator, or some other + entity that handles email. What they have in common is that at some + point they had access to the entire contents of the email and were in + a position to add their signature to the email. + +2.6. Originator + + The entity that creates and sends the initial form of a message. The + Originator adds the first Message-Instance header field (m=1) and the + first DKIM2-Signature header field (i=1) to the message. + +2.7. Header Field + + As defined in [RFC5322], a header field is a single logical line in + the message header consisting of a field name, a colon, and a field + body (value). In this document "header field" always refers to a + single field; "header fields" (plural) refers to multiple fields. + The unqualified term "header" is avoided to prevent ambiguity. + +2.8. Tag + + A named element within a header field (see Section 6 and Section 7). + A tag consists of a tag-name and a tag-value separated by an equals + sign. Tags are separated by semicolons within the header field. + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 6] + +Internet-Draft DKIM2 Signatures April 2026 + + +2.9. Message Body + + The content of an email message that follows the blank line after the + header fields, treated as a sequence of octets. In this document, + the terms "body" and "message body" are used interchangeably. + +2.10. Hash + + A fixed-length value produced by applying a cryptographic hash + function (such as SHA-256) to an input. DKIM2 uses hashes to create + a compact, verifiable representation of message header fields and the + message body. + +2.11. Glossary + + The following terms are used throughout this document: + + DKIM1 The original DomainKeys Identified Mail protocol as specified + in [RFC6376]. + + DKIM2-Signature A header field containing a cryptographic signature + over the Message-Instance and DKIM2-Signature header fields of a + message, along with metadata about the signing domain, SMTP + envelope, and timestamp. + + Message-Instance A header field containing cryptographic hashes of + the message header fields and body, along with optional recipes + that allow undoing changes made at that hop. + + Recipe A set of instructions encoded as a JSON object within the r= + tag of a Message-Instance header field. Recipes allow a Verifier + to reconstruct the previous state of a message from its current + state, by specifying which parts of the header fields or body to + copy and which literal values to substitute. + + Chain of Custody The sequence of DKIM2-Signature header fields on a + message, each recording the SMTP envelope addresses (MAIL FROM and + RCPT TO) used at each hop. A valid chain of custody demonstrates + that the message followed a plausible path from Originator to the + current recipient. + + Selector A subdivision of the key namespace for a signing domain, + used to look up the public key in DNS. The selector value is + combined with the signing domain to form the DNS query name: + selector._domainkey.domain. + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 7] + +Internet-Draft DKIM2 Signatures April 2026 + + +2.12. Whitespace + + There are two forms of whitespace used in this specification: + + * WSP represents simple whitespace, i.e., a space or a tab character + (formal definition in [RFC5234]). + + * FWS is folding whitespace. It allows multiple lines separated by + CRLF followed by at least one whitespace, to be joined. + + The formal ABNF for these are (WSP given for information only): + + WSP = SP / HTAB + FWS = [*WSP CRLF] 1*WSP + + The definition of FWS is identical to that in [RFC5322] except for + the exclusion of obs-FWS. + +2.13. Imported ABNF Tokens + + The following tokens are imported from other RFCs as noted. Those + RFCs should be considered definitive. + + The following tokens are imported from [RFC5321]: + + * "Domain" + + * "Forward-path" + + * "reverse-path" + + The following tokens are imported from [RFC5322]: + + * "field-name" (name of a header field) + + Other tokens not defined herein are imported from [RFC5234]. These + are intuitive primitives such as SP, HTAB, WSP, ALPHA, DIGIT, CRLF, + etc. + +2.14. Common ABNF Tokens + + The following ABNF tokens are used elsewhere in this document: + + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 8] + +Internet-Draft DKIM2 Signatures April 2026 + + + ALPHADIGITD = (ALPHA / DIGIT / "-" / "_") + + textstring = [FWS] ALPHADIGITD *(ALPHADIGITD) [FWS] + + ALPHADIGITPS = (FWS / ALPHA / DIGIT / "+" / "/") + + base64string = ALPHADIGITPS *(ALPHADIGITPS) [[FWS] "=" [[FWS] "="]] + + Note that base64strings are defined in [RFC4648], but that document + does not contain any ABNF. Note that a base64string MUST be padded + with trailing = characters if needed. + + Note that the definition of base64string allows for the presence of + FWS, which simplifies folding header fields to an allowable line + length. FWS within base64strings will be ignored when their value is + being used. + +3. Signing and Verification Cryptographic Algorithms + + DKIM2 supports multiple hashing and digital signature algorithms. + One hash function (SHA256) is specified here and two signing + algorithms are defined by this specification: RSA-SHA256 and + Ed25519-SHA256. Signers and Verifiers MUST implement SHA256. + Signers SHOULD implement both RSA-SHA256 and Ed25519-SHA256. + Verifiers MUST implement both RSA-SHA256 and Ed25519-SHA256. + +3.1. The SHA256 Hashing Algorithm + + The SHA256 hashing algorithm is used to compute body and header + hashes as defined in Section 5.1 and Section 5.2. + + The resultant values are identified by the text string "sha256" and + placed into Message-Instance header fields. + +3.2. The RSA-SHA256 Signing Algorithm + + The RSA-SHA256 Signing Algorithm computes a hash over all the + Message-Instance and DKIM2-Signature header fields as described in + Section 8.5 using SHA-256 (FIPS-180-4-2015) as the hash-alg. That + hash is then signed by the Signer using the RSA algorithm (defined in + PKCS#1 version 1.5 [RFC8017]) as the crypt-alg and the Signer's + private key. The hash MUST NOT be truncated or converted into any + form other than the native binary form before being signed. The + signing algorithm MUST use a public exponent of 65537. + + Signers MUST use RSA keys of at least 1024 bits. Verifiers MUST be + able to validate signatures with keys ranging from 1024 bits to 2048 + bits, and they MAY be able to validate signatures with larger keys. + + + +Clayton, et al. Expires 22 October 2026 [Page 9] + +Internet-Draft DKIM2 Signatures April 2026 + + + The signature value (expressed in base64) is placed (with the + identifying text string "rsa-sha256") into DKIM2-Signature header + fields. + +3.3. The Ed25519-SHA256 Signing Algorithm + + The Ed25519-SHA256 Signing Algorithm computes a hash over all the + Message-Instance and DKIM2-Signature fields as described in + Section 8.5 using SHA-256 (FIPS-180-4-2015) as the hash-alg. It + signs the hash with the PureEdDSA variant Ed25519, as defined in + Section 5.1 of [RFC8032]. + + The signature value (expressed in base64) is placed (with the + identifying text string "ed25519-sha256") into DKIM2-Signature header + fields. + +3.4. Other Algorithms + + Other algorithms MAY be defined in the future. Verifiers MUST ignore + any hashes or signatures using algorithms that they do not implement. + +3.5. Selectors + + To support multiple concurrent public keys per signing domain, the + key namespace is subdivided using "selectors". + + The number of public keys and corresponding selectors for each domain + is determined by the domain owner. Many domain owners will use just + one selector, whereas administratively distributed organizations can + choose to manage disparate selectors and key pairs in different + regions or on different email servers. Selectors can also be used to + delegate a signing authority, which can be withdrawn at any time. + Selectors also make it possible to seamlessly replace keys on a + routine basis by signing with a new selector, while keeping the key + associated with the old selector available. + + Periods are allowed in selectors and are component separators. + Periods in selectors define DNS label boundaries in a manner similar + to the conventional use in domain names. This will allow portions of + the selector namespace to be delegated. + + ABNF: + + selector = Domain + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 10] + +Internet-Draft DKIM2 Signatures April 2026 + + +3.6. Key Management + + Some level of assurance is required that a public key is associated + with the claimed Signer. DKIM2 does this by fetching the key from + the DNS for the domain specified in the d= field of the + DKIM2-Signature header field. + + DKIM2 keys are stored in a subdomain named "_domainkey". Given a + DKIM2-Signature field with a "d=" tag of "example.com" and a selector + of "foo.bar", the DNS query will be for + "foo.bar._domainkey.example.com". + + NOTE: these keys are no different, and are stored in the same + locations as those for DKIM1 ([RFC6376]). + + Further details can be found in [DKIMKEYS]. + +4. Recipes + + A set of "recipes" is used to recreate the previous version of the + body and/or header fields of a message. The recipes are provided + within a JSON object with the schema: + + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkim2.org/schemas/recipe-v1", + "title": "DKIM2 recipes", + "description": "see draft-dkim-dkim2-spec", + "type": "object", + "properties": { + "h": { + "description": [ "recipes to recreate header fields", + "keys are header field names matched case-insensitively", + "and there MUST NOT be keys that differ only in case"], + "oneOf": [ + { + "description": "per-field-name recipe arrays", + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/recipe-steps" } + }, + { + "description": "previous header state cannot be recreated", + "type": "null" + } + ] + }, + "b": { + + + +Clayton, et al. Expires 22 October 2026 [Page 11] + +Internet-Draft DKIM2 Signatures April 2026 + + + "description": "recipes to recreate the body", + "oneOf": [ + { + "description": "body recipes", + "$ref": "#/$defs/recipe-steps" + }, + { + "description": "previous body state cannot be recreated", + "type": "null" + }, + { + "description": "body was truncated (DSN)", + "type": "object", + "properties": { + "z": { "type": "boolean", "const": true } + }, + "required": ["z"], "additionalProperties": false + } + ] + } + }, + "anyOf": [ + { "required": ["h"] }, + { "required": ["b"] } + ], + "$defs": { + "recipe-steps": { + "type": "array", + "items": { + "oneOf": [ + { + "description": "copy lines/fields, start to end inclusive", + "type": "object", + "properties": { + "c": { "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, "maxItems": 2 + } + }, + "required": ["c"], "additionalProperties": false + }, + { + "description": "data lines/values to emit", + "type": "object", + "properties": { + "d": { "type": "array", + "items": { "type": "string" }, + "minItems": 1 + + + +Clayton, et al. Expires 22 October 2026 [Page 12] + +Internet-Draft DKIM2 Signatures April 2026 + + + } + }, + "required": ["d"], "additionalProperties": false + } + ] + } + } + } + } + + Note that the specification of JSON schemas is maintained by the JSON + Schema organisation, and the relevant specification document is + linked to by the $schema field in each JSON schema. + +4.1. Header Recipes + + A Header Recipe is an array of instructions applied to the specified + header fields with the given header field name. These instructions + are applied in order to the message which has been received so as to + recreate the message as it was before modifications were made. + + If there is no "h" field in the JSON object then there was no + modification to the header fields. + + If the "h" field value is null (there are no recipes for any header + field) then the previous state of the header fields cannot be + recreated. Verifiers of the message may be able to determine, by + seeing which entity makes this declaration, that this is acceptable + to them because, for example, that entity is providing a + contractually arranged service. + + Matching of header field names is always done without regard to case. + + If a header field name is not present in the JSON object then all + header fields with that header field name are to be retained. + + If the recipe array for a header field name that is present in the + JSON object is empty then all instances of that header field are to + be removed to reinstate the previous state of the message. + + Header fields are numbered "bottom up" (the opposite direction to the + body lines). That is to say, when walking the header fields from the + top of the message to end of the header fields then the last header + field instance encountered with any particular header field name is + numbered 1, the header field (with the same header field name) above + that is numbered 2, and so on. + + + + + +Clayton, et al. Expires 22 October 2026 [Page 13] + +Internet-Draft DKIM2 Signatures April 2026 + + + The header fields should be treated as being unwrapped (in the normal + [RFC5321] manner). That is, all of the physical lines that form a + single header field are processed under the same logical number. + + The recipes are processed in order and the resulting header fields + are emitted so that later header field will appear above earlier + header fields in the recreated message. + + Each recipe step is a JSON object with exactly one key: + + A "c" step has the form {"c": [start, end]}. The relevant header + field instances numbered from start to end inclusive, are to be + emitted. The start value of each "c" step MUST be in ascending order + and MUST be greater than the end value of all preceding "c" steps for + this header field name. + + A "d" step has the form {"d": ["value1", "value2", ...]}. Each string + in the array is treated as a value to which the relevant header field + name and a colon is prepended and a CRLF is appended and the + resultant string is then emitted. Note that the way in which hashes + are calculated (see Section 5.2) means that no heed needs to be taken + of wrapping or the case of the header field name. The text strings + MUST NOT contain CR or LF characters. If a string is empty then the + CRLF will immediately follow the header field name and colon. + +4.2. Body Recipes + + A Body Recipe is an array of instructions applied to the message body + which can recreate the message as it was before modifications were + made. + + If there is no "b" field in the JSON object then there was no + modification to the message body. Note that the JSON schema requires + either "h" or "b" to be present. + + If the "b" field is null (there are no recipes) then the previous + state of the message body cannot be recreated. Verifiers of the + message may be able to determine, by seeing which entity makes this + declaration, that this is acceptable to them because, for example, + that entity is providing a contractually arranged service. + + Body lines are numbered "top down" (the opposite direction to the + header fields). The first line of the body (immediately after the + blank line that indicates that there are no more header fields) is + numbered 1. + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 14] + +Internet-Draft DKIM2 Signatures April 2026 + + + The recipes are processed in order and the resulting body lines + fields are emitted so that later lines will appear below earlier + lines in the recreated message. + + Each recipe step is a JSON object with exactly one key: + + A "c" step has the form {"c": [start, end]}. The message body lines + from start to end, inclusive, are to be emitted. The start value of + each "c" step MUST be in ascending order and MUST be greater than the + end value of all preceding "c" steps. + + A "d" step has the form {"d": ["line1", "line2", ...]}. Each string + in the array has a CRLF appended and the resultant string is emitted. + The text strings MUST NOT contain CR or LF characters. If a string + is empty then just a CRLF is emitted. + + A "z" step has the form {"z": true} and indicates that the body was + truncated (see the DSN handling in Section 11). + +5. Message Hash Values + + A set of cryptographic "hashes" are used to record the current + message body and header fields. The hashes are placed into the h= + tag of a Message-Instance header field. + + To provide for algorithmic dexterity more that one hash value, using + a different algorithm MAY be supplied in the same Message-Instance + header field. + + Since Message-Instance header fields are ignored when calculating the + header hash value, the body hash and header hash may be calculated in + any convenient order. + +5.1. Computing the Body Hash + + The body of messages is treated as merely a string of octets. DKIM2 + messages MAY be either in plain-text or in MIME format; no special + treatment is afforded to MIME content. Message attachments in MIME + format MUST be included in the content that is signed. + + The DKIM2 body hash is calculated in the same manner as DKIM1's + "simple" scheme: + + All empty lines at the end of the message body are ignored. An empty + line is a line of zero length after removal of the line terminator. + If there is no body or no trailing CRLF on the message body, a CRLF + is added. That is "*CRLF" at the end of the body is converted to + "CRLF". + + + +Clayton, et al. Expires 22 October 2026 [Page 15] + +Internet-Draft DKIM2 Signatures April 2026 + + + No other changes are made to the body, which is then processed by the + relevant hash algorithm(s). The name of the hash and the hash value + (converted to base64 form) is then inserted into (Signers) or + compared to (Verifiers) the value of the "h=" tag of the Message- + Instance header field that is being created/verified. If multiple + hashes are calculated then multiple entries within the "h=" value + will be inserted/compared. + +5.2. Computing the Header Fields Hash + + The header fields hash calculation done by a Signer MUST apply the + following steps in the order given. A Verifier will need to do the + equivalent steps in order to check that the hash they have received + is correct. + + * Ignore some header fields + + When calculating the header field hash "Received" or "Return-Path" + header fields MUST be ignored. These are Trace headers as + described in [RFC5321] and serve only to document details of the + SMTP transmission process. + + When calculating the header field hash any header field with a + header field name starting with "X-" MUST be ignored. Currently + deployed email systems use these fields as proprietary Trace + headers which have no defined meaning for other systems and it + considerably simplifies reporting on changes to header fields to + ignore them. + + When calculating the header field hash any "Message-Instance" or + "DKIM2-Signature" header fields MUST be ignored. These header + fields will be included in the hash value that will be signed by a + DKIM2-Signature header field and it simplifies implementations if + they are not included twice, especially when determining whether + all modifications to a message have been correctly declared. + + When calculating the header field hash any "DKIM-Signature" header + fields and any header fields whose field name starts with "ARC-" + MUST be ignored. Not including DKIM1 and ARC signatures means + that systems that wish to add other types of signature as well as + a DKIM2 signature are free to do this in any convenient order. + + * Convert all header field names (not the header field values) to + lowercase. For example, convert "SUBJect: AbC" to "subject: AbC". + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 16] + +Internet-Draft DKIM2 Signatures April 2026 + + + * Unfold all header field continuation lines as described in + [RFC5322]; in particular, lines with terminators embedded in + continued header field values (that is, CRLF sequences followed by + WSP) MUST be interpreted without the CRLF. Implementations MUST + NOT remove the CRLF at the end of the header field value. + + * Convert all sequences of one or more WSP characters to a single SP + character. WSP characters here include those before and after a + line folding boundary. + + * Delete all WSP characters at the end of each unfolded header field + value. + + * Delete any WSP characters remaining before and after the colon + separating the header field name from the header field value. The + colon separator MUST be retained. + + * Place the header fields in alphabetical order by the header field + name. + + * If there is more than one header with the same header field name + then the header fields are placed in the order in which they were + likely to have been placed into the message header, that is from + the last within the header upwards (the same ordering as is used + in the header recipes (see Section 4.1). + + It is sometimes suggested that some MTAs re-order header fields + after they receive an email. If an MTA does change the order of + header fields with the same header field name (and those header + fields will be included in the hash calculation) then it is their + responsibility to recover the original order before verifying an + existing signature or passing a previously signed message to + another MTA that may wish to do such verification. + + * The hash(es) of the concatenated header fields are calculated. + + The name of the hash and the hash value (converted to base64 form) is + then inserted into (Signers) or compared to (Verifiers) the value of + the "h=" tag of the Message-Instance header field that is being + created/verified. If multiple hashes are calculated then multiple + entries within the "h=" value will be inserted/compared. + +6. The Message-Instance Header Field + + A Message-Instance header field documents the current contents of the + message and, in the case of a Reviser, records any relevant changes + that have been made to the incoming message. + + + + +Clayton, et al. Expires 22 October 2026 [Page 17] + +Internet-Draft DKIM2 Signatures April 2026 + + + The Message-Instance header field is a list of tag values as + described below. The m= and h= tags MUST be present. The r= tag is + optional. + + The tag identifiers (before the = sign) MUST be treated as case + insignificant, the tag value (after the = sign) is case significant. + The tags may appear in any order, but MUST be only one of each kind. + Unknown tags, for extensions, MUST be ignored. + + ABNF: + + mi-field = "Message-Instance:" mi-tag-list + mi-tag-list = *([FWS] mi-tag [FWS] ";" [FWS]) + mi-tag = mi-m-tag / mi-h-tag / mi-r-tag / x-tag + x-tag = ALPHA *(ALPHA / DIGIT / "_") "=" %x21-3A / %x3C-7E + ; for extension + +6.1. m= the revision number of the Message-Instance header field + + The Originator of a message uses the value 1. Further Message- + Instance header fields are added with a value one more than the + current highest numbered Message-Instance header field. Gaps in the + numbering MUST be treated as making the whole message impossible to + verify. + + ABNF: + + mi-m-tag = %x6d [FWS] "=" [FWS] 1*DIGIT + +6.2. r= recipes to recreate the previous instance of the message + + The r= tag value is the base64 encoded version of the JSON object + that contains the recipes that allow the previous instance of the + message to be recreated (see Section 4}. + + ABNF: + + mi-r-tag = %x72 [FWS] "=" base64string + +6.3. h= the hash values for the message + + The h= tag value contains the hash name, header hash value and body + hash value. Calculating the hash values is explained in Section 5. + + ABNF: + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 18] + +Internet-Draft DKIM2 Signatures April 2026 + + + mi-h-tag = %x68 [FWS] "=" hash-set *("," hash-set ) + hash-set = [FWS] hash-name [FWS] ":" header-hash ":" body-hash + hash-name = "sha256" / x-hash-name + header-hash = base64string + body-hash = base64string + x-hash-name = textstring ; for later expansion + +7. The DKIM2-Signature Header Field + + The signature of the email is stored in a DKIM2-Signature header + field. This header field contains tag values that provide the + signature and key-fetching data. The i=, m=, t=, mf=, rt=, d= and s= + tags MUST be present. The other tags are optional. + + The tag identifiers (before the = sign) MUST be treated as case + insignificant, the tag value (after the = sign) is case significant. + The tags may appear in any order, but MUST be only one of each kind. + Unknown tags, for extensions, MUST be ignored. + + ABNF: + + sig-field = "DKIM2-Signature:" sig-tag-list + sig-tag-list = *([FWS] sig-tag [FWS] ";" [FWS]) + sig-tag = sig-i-tag / sig-m-tag / sig-t-tag / sig-mf-tag / + sig-rt-tag / sig-d-tag / sig-s-tag / sig-n-tag / + sig-f-tag / x-tag + + It will be noted that we have not included a version number. + Experience from IMF onwards shows that it is essentially impossible + to change version numbers. If it becomes necessary to change DKIM2 + in the sort of incompatible way that a v=2 / v=3 version number would + support, it is expected that header fields will be labelled as DKIM3 + instead. + +7.1. i= the sequence number of the DKIM2-Signature header field + + The Originator of a message uses the value 1. Further + DKIM2-Signature header fields are added with a value one more than + the current highest numbered DKIM2-Signature header field. Gaps in + the numbering MUST be treated as making the whole message unsigned. + + ABNF: + + sig-i-tag = %x69 [FWS] "=" [FWS] 1*DIGIT + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 19] + +Internet-Draft DKIM2 Signatures April 2026 + + +7.2. m= the highest numbered Message-Instance header field + + This value allows verifiers to determine which entity made a + particular revision to the message header fields or body. + + ABNF: + + sig-m-tag = %x6d [FWS] "=" [FWS] 1*DIGIT + +7.3. n= nonce value + + This text value, if present, has a meaning to the creator of the + signature but MUST NOT be assumed to have any meaning to any other + entity. It MAY be used as an index into a database to assist in + handling Delivery Status Notifications or for any other purpose. + + To discourage use of this tag field as an alternative to the use of + more appropriate header fields, the length of the string MUST NOT + exceed 64 characters and implementations SHOULD reject messages where + this limit has been ignored. + + Note the value MUST be simple ASCII and MUST NOT contain semicolon. + + ABNF: + + sig-n-tag = %x6e [FWS] "=" [FWS] nonce-value + nonce-value = *64(%x21-3A / %x3C-7E) + ; printable ASCII except semicolon, max 64 chars + +7.4. t= signature timestamp + + The time that this header field was created. The format is the + number of seconds since 00:00:00 on January 1, 1970 in the UTC time + zone. The value is expressed as an unsigned integer in decimal + ASCII. This value is not constrained to fit into a 31- or 32-bit + integer. + + Implementations SHOULD be prepared to handle values up to at least + 10^12 (until approximately AD 200,000; this fits into 40 bits). + + Implementations MAY ignore signatures that have a timestamp in the + future. Implementations MAY ignore signatures that are more than 14 + days old. + + ABNF: + + sig-t-tag = %x74 [FWS] "=" [FWS] 1*DIGIT + + + + +Clayton, et al. Expires 22 October 2026 [Page 20] + +Internet-Draft DKIM2 Signatures April 2026 + + +7.5. mf= the MAIL FROM used when the message was sent + + DKIM2 records the [RFC5321] MAIL FROM value that was used when the + message was transmitted over an SMTP link from the signing MTA. Note + that MAIL FROM may be just "<>", for example for a Delivery Status + Notification. + + The value is recorded as the base64 encoding of the [RFC5321] + reverse-path because of the complex syntax of reverse-path values + (which can include characters which would confuse naive parsers of + DKIM2-Signature header fields). The angle brackets MUST be included, + but any "Mail-parameters" that were present after the reverse-path + MUST NOT be included. + + ABNF: + + sig-mf-tag = %x6d %x66 [FWS] "=" base64string + +7.6. rt= the RCPT TO value(s) used when the message was sent + + DKIM2 records the [RFC5321] RCPT TO value(s) that were used when the + message was transmitted over an SMTP link from the signing MTA. + + The value is recorded as the base64 encoding of the [RFC5321] + Forward-path because of the complex syntax of Forward-path values + (which can include characters which would confuse naive parsers of + DKIM2-Signature header fields). The angle brackets MUST be included, + but any "Rcpt-parameters" that were present after the Forward-path + MUST NOT be included. + + When a message is intended for more than one recipient then the RCPT + TO values provided MAY include all of the recipients so that a single + copy of the email MAY be sent to all of the recipients in a single + SMTP transaction. Alternatively, multiple copies of the email may be + generated so as to not immediately reveal who else received the + email. + + However, if "bcc:" recipients are involved then in order to meet the + requirements of [RFC5322] Section 3.6.3 each and every bcc recipients + MUST NOT revealed to any other message recipient. + + ABNF: + + sig-rt-tag = %x72 %x74 [FWS] "=" base64string *("," base64string) + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 21] + +Internet-Draft DKIM2 Signatures April 2026 + + +7.7. d= the domain associated with this signature. + + This domain is used to form the query for the public key. The domain + MUST be a valid DNS name under which the DKIM2 key record is + published. + + The domain name in the d= tag MUST exactly match the rightmost labels + of the domain name of the mf= tag. That is to say, the domain name + of the mf= tag MUST either match the d= domain exactly or be a sub- + domain of the d= domain name. + + When the mf= domain is empty ("<>"), as will be the case for Delivery + Status Notifications (DSNs), then no match is required. + + ABNF: + + sig-d-tag = %x64 [FWS] "=" [FWS] Domain + +7.8. s= the signature value(s) for the message + + The s= tag value contains the selector, signature algorithm name and + signature value. Calculating the value is explained in Section 8.5. + + The selector values subdivides the namespace for the domain being + used for signing. + + The algorithm value is the one used to generate the signature. + Verifiers MUST support "RSA-SHA256" for which the string "rsa-sha256" + is used and "Ed25519-SHA256" for which the string "ed25519-sha256" is + used. See Section 3 for a description of these algorithms. + + To provide for algorithmic dexterity more than one signature, using + different algorithms, MAY be supplied. Since the DNS lookup for the + public key will check that the k= algorithm value matches, a + different selector MUST necessarily be used for each signature. + + ABNF: + + sig-s-tag = %x73 [FWS] "=" [FWS] sig-set *( "," sig-set ) + sig-set = selector [FWS] ":" [FWS] sig-name [FWS] ":" message-sig + sig-name = "rsa-sha256" / "ed25519-sha256" / x-sig-name + x-sig-name = textstring ; for later extension + message-sig = base64string + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 22] + +Internet-Draft DKIM2 Signatures April 2026 + + +7.9. f= flags + + Flags serve two purposes; they either report what has been done to + the message by the system creating the DKIM2-Signature or they make a + request to systems that handle the mail thereafter. Flags are + separated by commas, and optional white-space allows systems to add + several flags without creating long lines. + + If a flag value is not recognised it MUST be ignored. + + The flag values that report things are: + + "exploded": this message (identified by its unique header hash value + (recorded in the h= JSON object of the relevant Message-Instance) is + being sent to more than one email address. An MTA which receives a + message MAY use this information to help it distinguish between + malicious "DKIM replay" and legitimate activity performed by mailing + list. If this flag is not present in at least one DKIM2-Signature + header field then an MTA MAY assume that only one copy of a + particular message (identified by relevant cryptographic hash values) + is intended to exist; + + The flags values that make requests are: + + "donotexplode": this Signer requests that the message not be sent to + more than one recipient. A system that, by local policy, ignores + this request MUST NOT allow any of the copies it creates to be + forwarded on to any MTA outside its control. + + "donotmodify": this Signer requests that the message not be modified + from the form in which it is sent. A system that, by local policy, + ignores this request MUST NOT allow the message to be forwarded on to + any MTA outside its control. + + "feedback": this Signer requests feedback about how this message is + handled during delivery and thereafter. This document does not + describe what such feedback might be or where it might be delivered. + If this flag is absent then feedback is explicitly not required. + + ABNF: + + sig-f-tag = %x66 [FWS] "=" [FWS] sig-f-tag-data + *( [FWS] "," [FWS] sig-f-tag-data) + sig-f-tag-data = "donotmodify" | "donotexplode" | "feedback" | + "exploded" | x-sig-f-tag-data + x-sig-f-tag-data = textstring ; for later extension + + + + + +Clayton, et al. Expires 22 October 2026 [Page 23] + +Internet-Draft DKIM2 Signatures April 2026 + + +8. Signer Actions + + This section gives the actions that need to be undertaken by the + signer of a message. They may be done in any appropriate order. + +8.1. Add any Necessary Message-Instance Header Fields + + If a system is generating the initial form of a message or if it is a + Reviser that has made changes to the message body and/or header + fields then it MUST compute the body hash as described in Section 5.1 + and the hash of the header fields as described in Section 5.2. + + If the message does not contain a Message-Instance header field then + one MUST be added. + + If hashing the message body or relevant header fields does not give + the same hash values as those recorded in the highest version (m=) + Message-Instance header field then a new Message-Instance header + field MUST be added and if they are the same a new Message-Instance + header field SHOULD NOT be added. + + A Message-Instance header field MUST contain "recipes" to be able to + recreate the message corresponding to the hash values in the + currently highest numbered Message-Instance header field, or a null + recipe to indicate that recreating the previous version of the + message will not be possible. + + A system may add more than one Message-Instance header field if it + wishes to do so, but the DKIM2 design allows all modifications made + by any single system to be documented in a single Message-Instance + header field. + + Note that the first (m=1) Message-Instance header field MAY contain + "recipes" if it is wished to record any changes made to a message as + it enters the DKIM2 ecosystem. All other Message-Instance header + fields SHOULD contain at least one "recipe". + +8.2. Provide a "Chain of Custody" for the Message + + The DKIM2-Signature header field contains the MAIL FROM and RCPT TO + values that will be used when the message is transmitted, so these + [RFC5321] "envelope" values MUST be available to (or deducible by) a + Signer. + + The receiver of a message will check for an exact match (including + the local parts of the email addresses) between the MAIL FROM / RCPT + TO [RFC5321] protocol values and the mf= and rt= values in the + highest numbered (most recent) DKIM2-Signature header field. It is + + + +Clayton, et al. Expires 22 October 2026 [Page 24] + +Internet-Draft DKIM2 Signatures April 2026 + + + acceptable for there to be more RCPT TO email addresses recorded in + rt= than are actually used in the SMTP conversation, but any RCPT TO + value which is used MUST be present. + + Verifiers will check for a relaxed domain match (see Section 8.3) + between the signing domain (d=) and the domain in the MAIL FROM + value. + + When the message being signed already has a DKIM2-Signature header + field (i.e. it has already been transmitted at least once) then a + valid "chain of custody" MUST be apparent when all of the + DKIM2-Signature header fields are considered. This "chain of + custody" contributes to the way in which DKIM2 tackles "DKIM replay" + attacks. + + In any situation where a message will be forwarded in such a way that + the mf= on the outgoing message is such that the "chain of custody" + would be broken then the Signer MUST generate an extra + DKIM2-Signature header field that causes values to match, i.e. a + record must be fabricated that documents the mail being passed from + one domain to another. + + It will be noted that the creation of this extra header field will + require the Signer to have access to a DKIM2 private key associated + with a domain in the RCPT TO entry. This is often achieved by the + Signer creating the private key and never sharing it and then taking + one of two approaches to publishing the public key. The first is to + provide the public key (and selector value) to the domain owner who + creates an appropriate DNS entry. The alternative is for the Signer + to create a public key DNS entry within a part of the DNS that they + control and the domain owner publishes a CNAME pointing at this. + + If an MTA does not change anything in the message which would require + a new Message-Instance header field and it is going to send it + onwards to a system that be able to verify the existing message (that + is no changes are made to the MAIL FROM and RCPT TO values) and there + is no other reason to add a DKIM2-Signature header field then the MTA + MAY choose not to add one. This means that an essentially + transparent SMTP forwarding system need not be made "DKIM2 aware". + +8.3. The Relaxed Domain Match Algorithm + + To assist in addressing the "DKIM replay" problem DKIM2 provides a + "chain of custody" for every message. This is established by + checking that the MAIL FROM value recorded in every DKIM2-Signature + header field (except of course the i=1 instance) can be matched with + a RCPT TO value of the next lower numbered DKIM2-Signature header + field. + + + +Clayton, et al. Expires 22 October 2026 [Page 25] + +Internet-Draft DKIM2 Signatures April 2026 + + + It is also necessary to check DKIM2-Signature header fields for a + match between the signing domain (specified in the d= tag) and the + MAIL FROM domain. + + To allow systems to use existing "bounce-handling" schemes with + special subdomains in their MAIL FROM values a "relaxed" approach is + taken to the matches between these values. + + * Only the domain part of the MAIL FROM and RCPT TO values is used + for these matches The local part (and the @) are ignored. + + * If there is not an exact match between the domain names then + labels are removed, one by one from the left hand side of the MAIL + FROM domain name and the comparison is repeated. + + * If no labels remain then there is no match. + +8.4. Select a Private Key and Corresponding Selector Value + + This specification does not define the basis by which a Signer should + choose which private key and selector value to use -- this will be a + matter of administrative convenience. Distribution and management of + private keys is also outside the scope of this document. + +8.5. Calculate a Signature Value + + A Signer calculates a signature solely over the Message-Instance and + DKIM2-Signature header fields of the message. The hashes of the body + and other header fields are covered by the hashes in the highest + version (m=) Message-Instance header field and hence the signature + will in practice be signing the message as a whole. + + Most cryptographic schemes proceed by first calculating a hash value + and then signing the hash value, but the DKIM2-Signature header field + only provides the final signature value. This means that there is no + difficulty if the hash value is inordinately long, or is not emitted + by the cryptographic routine being used. + + The signature algorithm MUST apply the following steps in the order + given (which are not quite the same as the steps undertaken in + calculating header hashes). + + * Convert all relevant header field names (not the header field + values) to lowercase. For example, convert "DKIM2-signature" to + "dkim2-signature". + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 26] + +Internet-Draft DKIM2 Signatures April 2026 + + + * Unfold all header field continuation lines as described in + [RFC5322]; in particular, lines with terminators embedded in + continued header field values (that is, CRLF sequences followed by + WSP) MUST be interpreted without the CRLF. Implementations MUST + NOT remove the CRLF at the end of the header field value. + + * Delete all WSP characters. This means all WSP characters before + and after the colon separating the header field name from the + header field value, all WSP characters within the unfolded header + field value and all trailing WPS characters before the CRLF. The + colon separator and the CRLF MUST be retained. + + * Place the header fields in order. First come the Message-Instance + header fields in ascending instance (m=) order. Second are the + DKIM2-Signature header fields in ascending sequence (i=) order. + Last of all is an incomplete DKIM2-Signature header field (the one + that this system is creating) with all tags present except that + the signature value(s) within the (s=) value are set to the null + string (""). The incomplete header field MUST be unfolded, MUST + have a trailing CRLF and MUST have spaces removed in just the same + way as the complete header fields being processed. + + * The concatenated header fields are then fed to the signature + algorithm(s). Once all the values are available the null + signature value strings are replaced by the base64 values of the + signatures. + +9. Verification Requirements + + The details of verification appear in Section 10 below. This section + considers when verification should be performed and how thorough it + needs to be. + +9.1. Check Most Recent Signature and Hashes for the Message + + A Verifier SHOULD check the validity of the most recently applied + (highest numbered i= value) DKIM2-Signature header field and the + associated (m=) Message-Instance before accepting an email. + + If these checks do not pass then a Delivery Status Notification (DSN) + for the email MUST NOT be generated thereafter -- hence the best + strategy, if the email is not wanted, is to reject it (with a 5xx + error code) whilst the relevant SMTP conversation is still ongoing. + If the check gives a TEMPFAIL result then a 4xx error code SHOULD be + used to allow the sending MTA to understand the situation. + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 27] + +Internet-Draft DKIM2 Signatures April 2026 + + + If the checks do pass and it is later determined that the email is + unacceptable for any reason then a DSN MAY be created and passed to + the system that delivered the email. The details of this procedure + appear in Section 11. + +9.2. Checking the Message-Instance Header Fields + + If the message has been modified since its original creation then the + Message-Instance header fields will enable a Verifier to determine + whether or not all the changes made are correctly recorded by using + the "recipes" to construct each preceding version of the message. + + Note that if it is only the first form of the message is of interest + then all the "recipes" can be applied in turn and only one hash value + checked -- the correctness of the intermediate hash values are not + relevant to this assessment. + +9.3. Checking the DKIM2-Signature Header Fields + + However, in order to check the chain of custody, to assess whether + the message has been exploded, to pick out "feedback" requests to be + honoured or to assign reputation to Revisers then all of the + DKIM2-Signature header fields will have to checked for validity. The + TBA document explores these issues in more detail. + +9.4. Interpret Results/Apply Local Policy + + It is beyond the scope of this specification to describe what actions + the recipient of an email performs, but mail carrying valid DKIM2 + signatures gives the recipient opportunities that unauthenticated + email would not. Specifically, an authenticated email provides + predictable information by which other decisions can reliably be + managed, such as trust and reputation. Conversely, it is hard to + assign trust or reputation to unauthenticated email. + + If an MTA wishes to reject messages where signatures are missing or + do not verify, the handling MTA SHOULD use a 550/5.7.x reply code. + + Where the Verifier is integrated within the MTA and it is not + possible to fetch the public key, perhaps because the key server is + not available, a temporary failure message MAY be generated using a + 451/4.7.5 reply code. + + Temporary failures such as inability to access the key server or + other external service are the only conditions that SHOULD use a 4xx + SMTP reply code. In particular, cryptographic signature verification + failures MUST NOT provoke 4xx SMTP replies. + + + + +Clayton, et al. Expires 22 October 2026 [Page 28] + +Internet-Draft DKIM2 Signatures April 2026 + + +10. Verifier Actions + + This section discusses the detail of the actions taken by a Verifier. + In essence this will involve repeating all the actions taken by a + Signer to produce a Message-Instance or DKIM2-Signature header field. + To avoid a lot of repetition these actions will not be spelled out in + detail. Once a hash value has been calculated it is then compared + with the value reported by the Signer, or the Signer's public key is + used to determine whether a signature that has been provided is + correct. + + When a Verifier is determining whether a particular DKIM2-Signature + header field it MUST consider the state of the message when that + header field was added to the message. That means it MUST first + apply all relevant recipes to reconstruct the body and header fields + and it MUST ignore any Message-Instance and DKIM2-Signature fields + that were added after that point. + +10.1. Output States + + For compatibility with the Authentication-Results header field + defined in [RFC8601] a verification will result in one of four + states: + + PASS: The message was successfully verified. + + FAIL: The message could be verified but a hash or signature was not + correct. + + PERMERROR: The message could not be verified due to some error that + is unrecoverable, such as a required header field being absent or + malformed. + + TEMPERROR: The message could not be verified due a temporary + inability to retrieve a public key. A later attempt may produce a + different. + + A Verifier MAY cease verifying once a single failure is detected. + + + + + + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 29] + +Internet-Draft DKIM2 Signatures April 2026 + + + Verifiers wishing to communicate the results of verification to other + parts of the mail system may do so in whatever manner they see fit. + If they wish to provide a human-readable string to describe a failure + to verify (any state except PASS) then in order to provide the + maximum possible assistance to senders they SHOULD use the text + strings specified in this document. These human-readable messages + are described with m= or tag= placeholders, the and + MUST be replaced with the relevant ordinal or tag name (without the < + and > characters). Similarly MUST be replaced by a relevant + string for the particular message. + + If the verification is being performed during an SMTP protocol + conversation the human-readable string SHOULD be part of the 5xx or + 4xx response string. + + If the results of the verification are being communicated in a + Delivery Status Notification message ([RFC3461]) the human-readable + string should be included. + + If, by local policy, a system wishes to accept a message which has + failed authentication it might choose to add an email header field to + the message before passing it on. Any such header field SHOULD + include the human-readable string and SHOULD be inserted before any + existing DKIM2-Signature or pre-existing authentication status header + fields in the header field block. The Authentication-Results: header + field ([RFC8601]) MAY be used for this purpose. It should be noted + that any "Authentication-Results" header field will count as a + modification to the email if any further DKIM2-Signature header + fields are to be generated. + +10.2. Ensure that the DKIM2 Header Fields are Valid + + Verifiers MUST meticulously validate the format and values of all + relevant Message-Instance and DKIM2-Signature header fields. It MUST + also ensure that all required instances of these header fields are + present and that all required tags are present. Recall however that + unknown tags MUST be ignored. + + As a special case, there MUST NOT be a Message-Instance field with a + higher m= value than occurs in any DKIM2-Signature field. + + Possible errors: + + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 30] + +Internet-Draft DKIM2 Signatures April 2026 + + + PERMERROR Message-Instance m= missing + PERMERROR Message-Instance m= syntax error + PERMERROR Message-Instance m= tag= missing + PERMERROR Message-Instance m= is not signed + PERMERROR DKIM2-Signature i= missing + PERMERROR DKIM2-Signature i= syntax error + PERMERROR DKIM2-Signature i= tag= missing + +10.3. Check the timestamps + + Verifiers SHOULD return a failure it is more than 14 days since the + timestamp recorded in the "t=" tag of any DKIM2-Signature header + field. + + Possible errors: + + PERMERROR DKIM2-Signature i= signature expired + +10.4. Check the Chain-of-Custody + + As explained in Section 8.2 a Verifier MUST check an exact match + between the MAIL FROM and RCPT TO parameters used when delivering a + message and the values found in the mf= and rt= tags of the highest + numbered DKIM2-Signature header field. There may be extra values in + the rt= value, but all RCPT TO values actually used for delivery MUST + be present. + + The values of domains MUST BE put into lower-case before doing these + checks. As is usual in email protocols the case of the local part of + an email address is assumed to matter. Note that these checks MUST + NOT use the relaxed domain match algorithm. + + A Verifier SHOULD check that there is a relaxed domain match (see + {relaxed-domain-match}) between the signing domain of the most + recently applied DKIM2-Signature header field and the mf= value in + that header field. + + Possible errors: + + PERMERROR: MAIL FROM did not match + PERMERROR: RCPT TO did not match + PERMERROR: MAIL FROM and d= do not match + + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 31] + +Internet-Draft DKIM2 Signatures April 2026 + + +10.5. Fetch the Public Key + + The public keys of all the signatures in DKIM2-Signature fields are + needed to complete the verification process. Details of key + management and representation are described in Section 3.6 and + [DKIMKEYS]. The Verifier MUST validate the key record and MUST NOT + use any public key records that are malformed. + + Note that DNS timeouts MUST be reported as TEMPERROR but a DNS result + that indicates the key is absent MUST be reported as a PERMERROR. + Additionally, as [DKIMKEYS] makes clear, if more than one record is + returned this is an error. The human-readable error message SHOULD + provide the selector value so that it is clear which key has caused a + problem. + + Note that [DKIMKEYS] has retired the h= field and DKIM2 + implementations MUST ignore this tag if it is present. + + Possible errors: + +TEMPERROR: DKIM2-Signature i= public key could not be fetched +PERMERROR: DKIM2-Signature i= public key does not exist +PERMERROR: DKIM2-Signature i= public key has multiple records +PERMERROR: DKIM2-Signature i= public key has a syntax error +PERMERROR: DKIM2-Signature i= public key algorithm mismatch +PERMERROR: DKIM2-Signature i= public key has been revoked + +10.6. Perform the Signature Verification Calculation + + Verifying a signature consists of actions semantically equivalent to + the following steps: + + 1. Prepare a canonicalized version of the Message-Instance and + DKIM2-Signature header fields as described in Section 8.5. The + signature value(s) themselves will need to be removed to + correspond with what was actually signed. Note that this + canonicalized version does not actually replace the original + content. + + 2. Use the relevant public key value(s) to check the signature(s). + + 3. If there is more than one signature provided then they MUST all + be checked if the Verifier is able to do so. If any signature + fails then an error SHOULD be reported. If all signatures that + can be checked fail then PERMFAIL MUST be reported. + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 32] + +Internet-Draft DKIM2 Signatures April 2026 + + + 4. If some signatures fail and other pass then any error that is + reported should provide that information (e.g. PERMFAIL "rsa- + sha256 signature passed, ed25519-sha256 signature failed"). + + The reasoning for requiring that all signatures pass is that if a + signature scheme has recently become deprecated because it is known + to be cryptographically flawed then Signers will use a second + (unbroken) signature scheme. However, such a Signer may still + provide the other signature for the benefit of Verifiers that have + yet to upgrade -- reasoning perhaps that attacks are too expensive to + be a very significant security issue. A Verifier that determines + that one signature passes whilst the other fails may well be in a + position to prevent an attack. + + Possible errors: + + FAIL: DKIM2-Signature i= public key incorrect signature + +10.7. Validating Body and Header hashes + + Verifying a hash value requires a Verifier to repeat the hash + calculation performed by the Signer as set out in Section 5.1 and + Section 5.1. The values can then be directly compared. + + Since there may be more than one hash algorithm given the human- + readable error message SHOULD indicate which algorithm's result + failed to match. + + Possible errors: + + FAIL: Message Instance m= header hash mismatch + FAIL: Message Instance m= body hash mismatch + +11. Delivery Status Notifications in the DKIM2 ecosystem + + In the DKIM2 ecosystem, when a message cannot be delivered then this + is reported to the sending machine by means of an [RFC5321] return + code or, if the SMTP session has completed, by generating a Delivery + Status Notification (DSN, as defined in [RFC3461]. + + A DSN MUST be addressed to the MTA that sent the message. This + prevents "backscatter" by passing failures back along the chain of + MTAs that were in involved in passing the message forwards. This is + achieved by using the mf= tag from the highest numbered + DKIM2-Signature field. If this field is null ("mf=<>") then a DSN + MUST NOT be sent. + + + + + +Clayton, et al. Expires 22 October 2026 [Page 33] + +Internet-Draft DKIM2 Signatures April 2026 + + +11.1. DSN contents + + As set out in [RFC3461], the DSN has a top-level MIME part of type + multipart/report. Among other things, that MIME part must contain a + MIME part of type message/rfc822 that holds either the original + message exactly as it was submitted by the sending system or just the + header fields of that message. + + All relevant DKIM2-Signature header fields (and Message-Instance + header fields if the message body is supplied) MUST verify. The DSN + itself MUST have appropriate Message-Instance and DKIM2-Signature + fields, noting that the MAIL FROM to be used will be null ("<>"). + + If the message body has been truncated (rather than omitted + altogether) then in order to allow verification of the DNS contents a + Message-Instance header field MUST be added to the message with a + body recipe containing a {"z": true} step. + +11.1.1. Bounce Propagation + + A Forwarder which receives a DSN MAY decide to propagate this DSN to + the MAIL FROM address used to deliver the message to it (which can be + found in the relevant DKIM2-Signature header field). The DSN SHOULD + be handled in the usual way, with Message-Instance header fields + documenting any changes and a DKIM2-Signature field with an + incremented hop count value added. + + The Forwarder MAY alternatively decide to reconstruct the message (or + just the message header fields) as they were when the message was + delivered to the Forwarder and construct a DSN using that + information. The information in Message-Instance header fields can + be used to achieve this. The resultant DSN is sent to the MAIL FROM + address from the now highest numbered DKIM2-Signature header field. + Doing this will ensure that details of where the message was + forwarded to will not be revealed to the previous hop. + +11.1.2. Authentication of Inbound Bounce Notifications + + When a system receives a DKIM2 signed bounce notification, and the + included original message is also DKIM2 signed, it SHOULD verify that + this message (or just the header fields if the body is not present) + has not been altered. + + This means: + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 34] + +Internet-Draft DKIM2 Signatures April 2026 + + + 1. The DSN's DKIM2-Signature will have a signing domain that is + aligned with the recipient of the message that is being returned. + The recipient's address is located in the rt= tag of the last + (highest i= tag) DKIM2-Signature in the returned message. + + 2. The last (highest i= tag) DKIM2-Signature header field of the + returned message will be one that was generated by the system + receiving the bounce notification, determined by examining the d= + and mf= tags of that DKIM2-Signature header field. + + 3. The header fields of the embedded message (in the message/rfc822 + MIME part) can be verified. If the message body is present then + that can also be verified by inspecting the Message-Instance + header field(s). + + If the verification fails then the DSN MUST NOT be propagated any + further. If verification has been performed prior to accepting the + DSN from the sender the DSN SHOULD be rejected with a 550/5.7.x + return code. If the verification cannot be completed because of a + temporary issue (with DNS lookups) then a 4xx return code should be + used. + +12. Preventing Transport Conversions + + DKIM2's design is predicated on valid input. + + In order to be signed a message will need to be in "network normal" + format (text is ASCII encoded, lines are separated with CRLF + characters, etc.). + + A message that is not compliant with [RFC5322], [RFC2045], [RFC2047] + and other relevant message format standards can be subject to + attempts by intermediaries to correct or interpret such content. See + Section 8 of [RFC6409] for examples of changes that are commonly + made. Such "corrections" may invalidate DKIM2 signatures or have + other undesirable effects, including some that involve changes to the + way a message is presented to an end user. + + When calculating the hash on messages that will be transmitted using + base64 or quoted-printable encoding, Signers MUST compute the hash + after the encoding. Likewise, the Verifier MUST incorporate the + values into the hash before decoding the base64 or quoted-printable + text. However, the hash MUST be computed before transport-level + encodings such as SMTP "dot-stuffing" (the modification of lines + beginning with a "." to avoid confusion with the SMTP end-of-message + marker, as specified in [RFC5321]). + + + + + +Clayton, et al. Expires 22 October 2026 [Page 35] + +Internet-Draft DKIM2 Signatures April 2026 + + + Further, if the message contains local encoding that will be modified + before transmission, that modification to canonical [RFC5322] form + MUST be done before signing. In particular, bare CR or LF characters + (used by some systems as a local line separator convention) MUST be + converted to the SMTP-standard CRLF sequence before the message is + signed. Any conversion of this sort SHOULD be applied to the message + actually sent to the recipient(s), not just to the version presented + to the signing algorithm. + + More generally, the Signer MUST sign the message as it is expected to + be received by the Verifier rather than in some local or internal + form. + +13. EAI ([RFC6530]) Considerations for DKIM2 + + TBA + +14. IANA Considerations + + TBA + +15. Security Considerations + + TBA + +16. Changes from Earlier Versions + + draft-ietf-dkim-dkim2-spec-01 + + Additions to terninology. Improved ABNF. Removed definition of tag- + list and placed relevant text in the two header field definitions. + Untangled he description of what needs to be verified from the + description of how to verify and provided a list of human-readable + strings to generate for errors. + + draft-ietf-dkim-dkim2-spec-00 + + Removed JSON for hashes, signatures and SMTP parameters. Provided + valid JSON for recipes and added "z" for truncated body. Changed + algorithm names for signing. Simplified the canonicalisation + performed for the header fields signed by DKIM2-Signature. Changed + v= to m= for message instance numbering. + + General tidying up of specifying tag=value specifications and + associated ABNF. Various other fixes for issues flagged in WG. + + [[This section to be removed by RFC Editor]] + + + + +Clayton, et al. Expires 22 October 2026 [Page 36] + +Internet-Draft DKIM2 Signatures April 2026 + + +17. References + +17.1. Normative References + + [DKIMKEYS] Chuang, W., "Domain Name Specification for DKIM2", Work in + Progress, Internet-Draft, draft-chuang-dkim2-dns-04, 18 + March 2026, . + + [RFC1034] Mockapetris, P., "Domain names - concepts and facilities", + STD 13, RFC 1034, DOI 10.17487/RFC1034, November 1987, + . + + [RFC2045] Freed, N. and N. Borenstein, "Multipurpose Internet Mail + Extensions (MIME) Part One: Format of Internet Message + Bodies", RFC 2045, DOI 10.17487/RFC2045, November 1996, + . + + [RFC2047] Moore, K., "MIME (Multipurpose Internet Mail Extensions) + Part Three: Message Header Extensions for Non-ASCII Text", + RFC 2047, DOI 10.17487/RFC2047, November 1996, + . + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, + DOI 10.17487/RFC2119, March 1997, + . + + [RFC3461] Moore, K., "Simple Mail Transfer Protocol (SMTP) Service + Extension for Delivery Status Notifications (DSNs)", + RFC 3461, DOI 10.17487/RFC3461, January 2003, + . + + [RFC4648] Josefsson, S., "The Base16, Base32, and Base64 Data + Encodings", RFC 4648, DOI 10.17487/RFC4648, October 2006, + . + + [RFC5234] Crocker, D., Ed. and P. Overell, "Augmented BNF for Syntax + Specifications: ABNF", STD 68, RFC 5234, + DOI 10.17487/RFC5234, January 2008, + . + + [RFC5321] Klensin, J., "Simple Mail Transfer Protocol", RFC 5321, + DOI 10.17487/RFC5321, October 2008, + . + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 37] + +Internet-Draft DKIM2 Signatures April 2026 + + + [RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322, + DOI 10.17487/RFC5322, October 2008, + . + + [RFC6376] Crocker, D., Ed., Hansen, T., Ed., and M. Kucherawy, Ed., + "DomainKeys Identified Mail (DKIM) Signatures", STD 76, + RFC 6376, DOI 10.17487/RFC6376, September 2011, + . + + [RFC6409] Gellens, R. and J. Klensin, "Message Submission for Mail", + STD 72, RFC 6409, DOI 10.17487/RFC6409, November 2011, + . + + [RFC6530] Klensin, J. and Y. Ko, "Overview and Framework for + Internationalized Email", RFC 6530, DOI 10.17487/RFC6530, + February 2012, . + + [RFC8259] Bray, T., Ed., "The JavaScript Object Notation (JSON) Data + Interchange Format", STD 90, RFC 8259, + DOI 10.17487/RFC8259, December 2017, + . + + [RFC8601] Kucherawy, M., "Message Header Field for Indicating + Message Authentication Status", RFC 8601, + DOI 10.17487/RFC8601, May 2019, + . + +17.2. Informative References + + [CONCLUDEARC] + Adams, J. T. and J. R. Levine, "Concluding the ARC + Experiment", Work in Progress, Internet-Draft, draft- + adams-arc-experiment-conclusion-01, 4 December 2025, + . + + [RFC5598] Crocker, D., "Internet Mail Architecture", RFC 5598, + DOI 10.17487/RFC5598, July 2009, + . + + [RFC8017] Moriarty, K., Ed., Kaliski, B., Jonsson, J., and A. Rusch, + "PKCS #1: RSA Cryptography Specifications Version 2.2", + RFC 8017, DOI 10.17487/RFC8017, November 2016, + . + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 38] + +Internet-Draft DKIM2 Signatures April 2026 + + + [RFC8032] Josefsson, S. and I. Liusvaara, "Edwards-Curve Digital + Signature Algorithm (EdDSA)", RFC 8032, + DOI 10.17487/RFC8032, January 2017, + . + + [RFC8617] Andersen, K., Long, B., Ed., Blank, S., Ed., and M. + Kucherawy, Ed., "The Authenticated Received Chain (ARC) + Protocol", RFC 8617, DOI 10.17487/RFC8617, July 2019, + . + +Authors' Addresses + + Richard Clayton + Yahoo + Email: rclayton@yahooinc.com + + + Wei Chuang + Google + Email: weihaw@google.com + + + Bron Gondwana + Fastmail Pty Ltd + Level 2, 114 William Street + 3000 + Australia + Phone: +61 457 416 436 + Email: brong@fastmailteam.com + + + + + + + + + + + + + + + + + + + + + + +Clayton, et al. Expires 22 October 2026 [Page 39] diff --git a/stevea/spec/draft-ietf-dkim-dkim2-spec.md b/stevea/spec/draft-ietf-dkim-dkim2-spec.md new file mode 100644 index 0000000..51af49e --- /dev/null +++ b/stevea/spec/draft-ietf-dkim-dkim2-spec.md @@ -0,0 +1,1595 @@ +--- +title: DomainKeys Identified Mail Signatures v2 (DKIM2) +abbrev: DKIM2 Signatures +docname: draft-ietf-dkim-dkim2-spec-01 +submissionType: IETF +number: +date: +v: +category: std +updates: +ipr: trust200902 +keyword: Internet-Draft +stand_alone: yes +pi: [toc, sortrefs, symrefs] + +author: + - + ins: R. Clayton + name: Richard Clayton + org: Yahoo + email: rclayton@yahooinc.com + - + ins: W. Chuang + name: Wei Chuang + org: Google + email: weihaw@google.com + - + ins: B. Gondwana + name: Bron Gondwana + org: Fastmail Pty Ltd + street: Level 2, 114 William Street + code: 3000 + country: Australia + phone: "+61 457 416 436" + email: brong@fastmailteam.com + +normative: + RFC1034: + RFC2045: + RFC2047: + RFC2119: + RFC3461: + RFC4648: + RFC5234: + RFC5321: + RFC5322: + RFC6376: + RFC6409: + RFC6530: + RFC8259: + RFC8601: + DKIMKEYS: I-D.chuang-dkim2-dns + +informative: + RFC5598: + RFC8017: + RFC8032: + RFC8617: + CONCLUDEARC: I-D.adams-arc-experiment-conclusion + +--- abstract + +DomainKeys Identified Mail v2 (DKIM2) permits a person, role, or +organization that owns a signing domain to document that it has +handled an email message by associating their domain with the +message. This is achieved by providing a hash value that has +been calculated on the current contents of the message and then applying a cryptographic +signature that covers the hash values and other details about the +transmission of the message. Verification is performed by querying an entry +within the signing domain's DNS space to retrieve an appropriate public +key. As a message is transferred from author to recipient systems that +alter the body or header fields will provide details of their changes +and calculate new hash values. Further signatures +will be added to provide a validatable "chain". This permits validators +to identify the nature of changes made by intermediaries and apply a +reputation to the systems that made changed. DKIM2 also allows +recipients to detect when messages have been unexpectedly "replayed" +and will ensure that Delivery Status Notifications are only sent +to entities that were involved in the transmission of a message. + +--- middle + +# Introduction + +DomainKeys Identified Mail v2 (DKIM2) permits a person, role, or +organization to document that they have handled an email message by +associating a domain name [RFC1034] with the message [RFC5322]. A +public key signature is used to record that they have been able +to read the contents of the message and write to it. + +Verification of claims is achieved by fetching a public key stored +in the DNS under the relevant domain and then checking the signature. + +Message transit from author to recipient is through +Forwarders that typically make no substantive change to the message +content and thus preserve the DKIM2 signature. Where they do make +a change the changes they have made are documented so that +these can be "undone" and the original signature validated. + +When a message is forwarded from one system to another an +additional DKIM2 signature is added on each occasion. This chain +of custody assists validators in distinguishing between messages that +were intended to be sent to a particular email address and those +that are being "replayed" to that address. + +The chain of custody can also be used to ensure that delivery status +notifications are only sent to entities that were involved in the +transmission of a message. + +Organizations that process a message can add to their signature +a request for feedback as to any opinion (for example, that the +email was considered to be spam) that the eventual recipient of +the message wishes to share. + +## DKIM2 Architecture Documents + +Readers are advised to be familiar with the material in TBA, TBA and TBA +which provide the background for the development of DKIM2, an overview +of the service, and deployment and operations guidance and advice. + +# Terminology and Definitions + +This section defines terms used in the rest of the document. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", +"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and +"OPTIONAL" in this document are to be interpreted as described in +[RFC2119]. These words take their normative meanings only when they +are presented in ALL UPPERCASE. + +DKIM2 is designed to operate within the Internet Mail service, as +defined in [RFC5598]. Basic email terminology is taken from that +specification. + +DKIM2 inherits many ideas from DKIM ([RFC6376]) which, for clarity +we refer to in this specification as DKIM1. In addition, some features +were influenced by experience with (see [CONCLUDEARC]) the experimental +ARC protocol ([RFC8617]). + +Syntax descriptions use Augmented BNF (ABNF) [RFC5234]. + +This document uses JSON [RFC8259] to encode the "recipes" which +record changes made to a message header fields or body. +The JSON objects are then base64 encoded. This means that a +standard JSON parser can be used to create what may be quite +complex data structures. Unrecognised fields within JSON objects +MUST be ignored. + +## Signer + +Elements in the mail system that sign messages on behalf of a domain +are referred to as Signers. These may be MUAs (Mail User Agents), +MSAs (Mail Submission Agents), MTAs (Mail Transfer Agents), or other +agents such as mailing list "exploders". In general, any Signer will +be involved in the injection of a message into the message system in +some way. The key point is that a message must be signed before it +leaves the administrative domain of the Signer. + +## Forwarder + +[RFC5598] defines a Relay as transmitting or retransmitting a message +but states that it will not modify the envelope information or the +message content semantics. It also defines a Gateway as a hybrid of +User and Relay that connects heterogeneous mail services. In this +document we use the concept of a Forwarder which is an MTA that receives +a message and then, as an alternative to delivering it into a +destination mailbox, can forward it on to another system in an +automated, pre-determined, manner. + +## Reviser + +As will be seen, a Forwarder may alter the message content or header +fields, in such a way that existing signatures on the message will +no longer validate. If so, then a record will be made of these +changes. We call a Forwarder that makes such changes a Reviser. + +## Verifier + +Elements in the mail system that verify signatures are referred to as +Verifiers. These may be Forwarders, Revisers, MTAs, Mail Delivery +Agents (MDAs), or MUAs. +It is an expectation of DKIM2 that a recipient of a message will +wish to verify some or all signatures before determining whether or +not to accept the message or pass it on to another entity. + +## Signing Domain + +A domain name associated with a signature. This domain may be +associated with the author of an email, their organization, a +company hired to deliver the email, a mailing list operator, or +some other entity that handles email. What they have in common is +that at some point they had access to the entire contents of the +email and were in a position to add their signature to the email. + +## Originator + +The entity that creates and sends the initial form of a message. +The Originator adds the first Message-Instance header field (m=1) and +the first DKIM2-Signature header field (i=1) to the message. + +## Header Field + +As defined in [RFC5322], a header field is a single logical line in +the message header consisting of a field name, a colon, and a field +body (value). In this document "header field" always refers to a +single field; "header fields" (plural) refers to multiple fields. +The unqualified term "header" is avoided to prevent ambiguity. + +## Tag + +A named element within a header field (see {{hfMessageInstance}} +and {{hfDKIM2signature}}). A tag +consists of a tag-name and a tag-value separated by an equals sign. +Tags are separated by semicolons within the header field. + +## Message Body + +The content of an email message that follows the blank line after +the header fields, treated as a sequence of octets. In this document, +the terms "body" and "message body" are used interchangeably. + +## Hash + +A fixed-length value produced by applying a cryptographic hash +function (such as SHA-256) to an input. DKIM2 uses hashes to +create a compact, verifiable representation of message header fields +and the message body. + +## Glossary + +The following terms are used throughout this document: + +DKIM1 +: The original DomainKeys Identified Mail protocol as specified in +[RFC6376]. + +DKIM2-Signature +: A header field containing a cryptographic signature over the +Message-Instance and DKIM2-Signature header fields of a message, +along with metadata about the signing domain, SMTP envelope, and +timestamp. + +Message-Instance +: A header field containing cryptographic hashes of the message +header fields and body, along with optional recipes that allow +undoing changes made at that hop. + +Recipe +: A set of instructions encoded as a JSON object within the r= tag +of a Message-Instance header field. Recipes allow a Verifier to +reconstruct the previous state of a message from its current state, +by specifying which parts of the header fields or body to copy and +which literal values to substitute. + +Chain of Custody +: The sequence of DKIM2-Signature header fields on a message, each +recording the SMTP envelope addresses (MAIL FROM and RCPT TO) used +at each hop. A valid chain of custody demonstrates that the message +followed a plausible path from Originator to the current recipient. + +Selector +: A subdivision of the key namespace for a signing domain, used +to look up the public key in DNS. The selector value is combined +with the signing domain to form the DNS query name: +selector._domainkey.domain. + +## Whitespace + +There are two forms of whitespace used in this specification: + +* WSP represents simple whitespace, i.e., a space or a tab character + (formal definition in [RFC5234]). + +* FWS is folding whitespace. It allows multiple lines separated by + CRLF followed by at least one whitespace, to be joined. + +The formal ABNF for these are (WSP given for information only): + + WSP = SP / HTAB + FWS = [*WSP CRLF] 1*WSP + +The definition of FWS is identical to that in [RFC5322] except for +the exclusion of obs-FWS. + +## Imported ABNF Tokens + +The following tokens are imported from other RFCs as noted. Those +RFCs should be considered definitive. + +The following tokens are imported from [RFC5321]: + +* "Domain" + +* "Forward-path" + +* "reverse-path" + +The following tokens are imported from [RFC5322]: + +* "field-name" (name of a header field) + +Other tokens not defined herein are imported from [RFC5234]. These +are intuitive primitives such as SP, HTAB, WSP, ALPHA, DIGIT, CRLF, +etc. + +## Common ABNF Tokens + +The following ABNF tokens are used elsewhere in this document: + + ALPHADIGITD = (ALPHA / DIGIT / "-" / "_") + + textstring = [FWS] ALPHADIGITD *(ALPHADIGITD) [FWS] + + ALPHADIGITPS = (FWS / ALPHA / DIGIT / "+" / "/") + + base64string = ALPHADIGITPS *(ALPHADIGITPS) [[FWS] "=" [[FWS] "="]] + +Note that base64strings are defined in [RFC4648], but that document +does not contain any ABNF. Note that a base64string MUST be padded +with trailing = characters if needed. + +Note that the definition of base64string allows +for the presence of FWS, which simplifies folding header fields +to an allowable line length. FWS within base64strings will be +ignored when their value is being used. + +# Signing and Verification Cryptographic Algorithms {#algorithms} + +DKIM2 supports multiple hashing and digital signature algorithms. One +hash function (SHA256) is specified here and two signing algorithms +are defined by this specification: RSA-SHA256 and Ed25519-SHA256. +Signers and Verifiers MUST implement SHA256. Signers SHOULD implement +both RSA-SHA256 and Ed25519-SHA256. Verifiers MUST implement both +RSA-SHA256 and Ed25519-SHA256. + +## The SHA256 Hashing Algorithm + +The SHA256 hashing algorithm is used to compute body and +header hashes as defined in {{computing-body-hash}} and +{{computing-header-hash}}. + +The resultant values are identified by the text string "sha256" and +placed into Message-Instance header fields. + +## The RSA-SHA256 Signing Algorithm + +The RSA-SHA256 Signing Algorithm computes a hash over all the Message-Instance +and DKIM2-Signature header fields as described in {{calculate-signature}} using +SHA-256 (FIPS-180-4-2015) as the hash-alg. That +hash is then signed by the Signer using the RSA algorithm (defined in +PKCS#1 version 1.5 [RFC8017]) as the crypt-alg and the Signer's +private key. The hash MUST NOT be truncated or converted into any +form other than the native binary form before being signed. The +signing algorithm MUST use a public exponent of 65537. + +Signers MUST use RSA keys of at least 1024 bits. Verifiers MUST be able +to validate signatures with keys ranging from 1024 bits to 2048 bits, and +they MAY be able to validate signatures with larger keys. + +The signature value (expressed in base64) is placed (with the identifying +text string "rsa-sha256") into DKIM2-Signature header fields. + +## The Ed25519-SHA256 Signing Algorithm + +The Ed25519-SHA256 Signing Algorithm computes a hash over all the Message-Instance +and DKIM2-Signature fields as described in {{calculate-signature}} using +SHA-256 (FIPS-180-4-2015) as the hash-alg. It signs the hash with the PureEdDSA +variant Ed25519, as defined in Section 5.1 of [RFC8032]. + +The signature value (expressed in base64) is placed (with the identifying +text string "ed25519-sha256") into DKIM2-Signature header fields. + +## Other Algorithms + +Other algorithms MAY be defined in the future. Verifiers MUST ignore +any hashes or signatures using algorithms that they do not implement. + +## Selectors + +To support multiple concurrent public keys per signing domain, the +key namespace is subdivided using "selectors". + +The number of public keys and corresponding selectors for each domain +is determined by the domain owner. Many domain owners will use just one +selector, whereas administratively distributed organizations can choose +to manage disparate selectors +and key pairs in different regions or on different email servers. +Selectors can also be used to delegate a signing authority, which +can be withdrawn at any time. Selectors also make it possible to +seamlessly replace keys on a routine basis by signing with a new +selector, while keeping the key associated with the old selector +available. + +Periods are allowed in selectors and are component separators. Periods in +selectors define DNS label boundaries in a manner similar to the +conventional use in domain names. This will allow portions of +the selector namespace to be delegated. + +ABNF: + + selector = Domain + +## Key Management {#key_management} + +Some level of assurance is required that +a public key is associated with the claimed Signer. DKIM2 +does this by fetching the key from the DNS for the domain specified +in the d= field of the DKIM2-Signature header field. + +DKIM2 keys are stored in a subdomain named "_domainkey". Given a +DKIM2-Signature field with a "d=" tag of "example.com" and a selector +of "foo.bar", the DNS query will be for "foo.bar._domainkey.example.com". + +NOTE: these keys are no different, and are stored in the same locations +as those for DKIM1 ([RFC6376]). + +Further details can be found in [DKIMKEYS]. + +# Recipes {#JSONrecipe} + +A set of "recipes" is used to recreate the previous version of the body +and/or header fields of a message. The recipes are provided +within a JSON object with the schema: + + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkim2.org/schemas/recipe-v1", + "title": "DKIM2 recipes", + "description": "see draft-dkim-dkim2-spec", + "type": "object", + "properties": { + "h": { + "description": [ "recipes to recreate header fields", + "keys are header field names matched case-insensitively", + "and there MUST NOT be keys that differ only in case"], + "oneOf": [ + { + "description": "per-field-name recipe arrays", + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/recipe-steps" } + }, + { + "description": "previous header state cannot be recreated", + "type": "null" + } + ] + }, + "b": { + "description": "recipes to recreate the body", + "oneOf": [ + { + "description": "body recipes", + "$ref": "#/$defs/recipe-steps" + }, + { + "description": "previous body state cannot be recreated", + "type": "null" + }, + { + "description": "body was truncated (DSN)", + "type": "object", + "properties": { + "z": { "type": "boolean", "const": true } + }, + "required": ["z"], "additionalProperties": false + } + ] + } + }, + "anyOf": [ + { "required": ["h"] }, + { "required": ["b"] } + ], + "$defs": { + "recipe-steps": { + "type": "array", + "items": { + "oneOf": [ + { + "description": "copy lines/fields, start to end inclusive", + "type": "object", + "properties": { + "c": { "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, "maxItems": 2 + } + }, + "required": ["c"], "additionalProperties": false + }, + { + "description": "data lines/values to emit", + "type": "object", + "properties": { + "d": { "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + }, + "required": ["d"], "additionalProperties": false + } + ] + } + } + } + } + +Note that the specification of JSON schemas is maintained by the JSON Schema +organisation, and the relevant specification document is linked to by the +$schema field in each JSON schema. + +## Header Recipes {#header-recipes} + +A Header Recipe is an array of instructions applied to the specified +header fields with the given header field name. These instructions +are applied in order to the message which has been received +so as to recreate the message as it was before modifications were made. + +If there is no "h" field in the JSON object then there was no +modification to the header fields. + +If the "h" field value is null (there are no recipes for +any header field) then the previous state of the header fields +cannot be recreated. Verifiers of the message may be able to +determine, by seeing which entity makes this declaration, that +this is acceptable to them because, for example, that entity +is providing a contractually arranged service. + +Matching of header field names is always done without regard to case. + +If a header field name is not present in the JSON object then all +header fields with that header field name are to be retained. + +If the recipe array for a header field name that is present in the +JSON object is empty then all instances of that header field are to +be removed to reinstate the previous state of the message. + +Header fields are numbered "bottom up" (the opposite direction to +the body lines). That is to say, when walking the header fields +from the top of the message to end of the header fields then +the last header field instance +encountered with any particular header field name is numbered 1, +the header field (with the same header field name) above that is +numbered 2, and so on. + +The header fields should be treated as +being unwrapped (in the normal [RFC5321] manner). That is, all +of the physical lines that form a single header field are +processed under the same logical number. + +The recipes are processed in order and the resulting header +fields are emitted so that later header field will appear above +earlier header fields in the recreated message. + +Each recipe step is a JSON object with exactly one key: + +A "c" step has the form {"c": \[start, end\]}. The relevant header field +instances numbered from start to end inclusive, are to be emitted. +The start value of each "c" step MUST be in ascending order and +MUST be greater than the end value of all preceding "c" steps +for this header field name. + +A "d" step has the form {"d": \["value1", "value2", ...\]}. Each +string in the array is treated as a value to which the +relevant header field name and a colon is prepended and a CRLF +is appended and the resultant string is then emitted. Note that +the way in which hashes are calculated (see {{computing-header-hash}}) +means that no heed needs to be taken of wrapping +or the case of the header field name. The text strings MUST NOT +contain CR or LF characters. If a string is empty then the +CRLF will immediately follow the header field name and colon. + +## Body Recipes {#body-recipes} + +A Body Recipe is an array of instructions applied to the message +body which can recreate the message as it was before modifications +were made. + +If there is no "b" field in the JSON object then there was no +modification to the message body. Note that the JSON schema +requires either "h" or "b" to be present. + +If the "b" field is null (there are no recipes) then the previous +state of the message body +cannot be recreated. Verifiers of the message may be able to +determine, by seeing which entity makes this declaration, that +this is acceptable to them because, for example, that entity +is providing a contractually arranged service. + +Body lines are numbered "top down" (the opposite direction to +the header fields). The first line of the body (immediately after +the blank line that indicates that there are no more header fields) +is numbered 1. + +The recipes are processed in order and the resulting body lines +fields are emitted so that later lines will appear below +earlier lines in the recreated message. + +Each recipe step is a JSON object with exactly one key: + +A "c" step has the form {"c": \[start, end\]}. The message body lines +from start to end, inclusive, are to be emitted. The start value of +each "c" step MUST be in ascending order and MUST be greater than the +end value of all preceding "c" steps. + +A "d" step has the form {"d": \["line1", "line2", ...\]}. Each +string in the array has a CRLF +appended and the resultant string is emitted. The text strings MUST NOT +contain CR or LF characters. If a string is empty then just +a CRLF is emitted. + +A "z" step has the form {"z": true} and indicates that the +body was truncated (see the DSN handling in {{bounce}}). + +# Message Hash Values {#messagehashes} + +A set of cryptographic "hashes" are used to record the current +message body and header fields. The hashes are placed into the +h= tag of a Message-Instance header field. + +To provide for algorithmic dexterity more that one hash value, using a +different algorithm MAY be supplied in the same Message-Instance +header field. + +Since Message-Instance header fields are ignored when calculating the +header hash value, the body hash and header hash may be calculated in +any convenient order. + +## Computing the Body Hash {#computing-body-hash} + +The body of messages is treated as merely a string of octets. DKIM2 +messages MAY be either in plain-text or in MIME format; no special +treatment is afforded to MIME content. Message attachments in MIME +format MUST be included in the content that is signed. + +The DKIM2 body hash is calculated in the same manner as DKIM1's "simple" +scheme: + +All empty lines at the end of the message body are ignored. An empty line +is a line of zero length after removal of the line terminator. If there +is no body or no trailing CRLF on the message body, a CRLF is added. That +is "*CRLF" at the end of the body is converted to "CRLF". + +No other changes are made to the body, which is then processed by the +relevant hash algorithm(s). The name of the hash and the hash value +(converted to base64 form) is then inserted into (Signers) or compared +to (Verifiers) the value of the "h=" tag of the Message-Instance header +field that is being created/verified. If multiple hashes are calculated +then multiple entries within the "h=" value will be inserted/compared. + +## Computing the Header Fields Hash {#computing-header-hash} + +The header fields hash calculation done by a Signer MUST apply the +following steps in the order given. A Verifier will need to do the +equivalent steps in order to check that the hash they have received +is correct. + +* Ignore some header fields + + When calculating the header field hash "Received" or "Return-Path" + header fields MUST be ignored. + These are Trace headers as described in [RFC5321] + and serve only to document details of the SMTP transmission process. + + When calculating the header field hash any header field with + a header field name starting with "X-" MUST be ignored. + Currently deployed email systems use these fields as + proprietary Trace headers which have no defined meaning for + other systems and it considerably simplifies reporting + on changes to header fields to ignore them. + + When calculating the header field hash any "Message-Instance" or + "DKIM2-Signature" header fields MUST be ignored. These header + fields will be included in the hash value that will be signed + by a DKIM2-Signature header field and it simplifies implementations + if they are not included twice, especially when determining + whether all modifications to a message have been correctly declared. + + When calculating the header field hash any "DKIM-Signature" header + fields and any header fields whose field name starts with "ARC-" + MUST be ignored. Not including + DKIM1 and ARC signatures means that systems that wish to add other + types of signature as well as a DKIM2 signature are free to do this + in any convenient order. + +* Convert all header field names (not the header field values) to + lowercase. For example, convert "SUBJect: AbC" to "subject: AbC". + +* Unfold all header field continuation lines as described in + [RFC5322]; in particular, lines with terminators embedded in + continued header field values (that is, CRLF sequences followed by + WSP) MUST be interpreted without the CRLF. Implementations MUST + NOT remove the CRLF at the end of the header field value. + +* Convert all sequences of one or more WSP characters to a single SP + character. WSP characters here include those before and after a + line folding boundary. + +* Delete all WSP characters at the end of each unfolded header field + value. + +* Delete any WSP characters remaining before and after the colon + separating the header field name from the header field value. The + colon separator MUST be retained. + +* Place the header fields in alphabetical order by the header field + name. + +* If there is more than one header with the same header field name + then the header fields are placed in the order in which they were + likely to have been placed into the message header, that is from + the last within the header upwards (the same ordering as is used + in the header recipes (see {{header-recipes}}). + + It is sometimes suggested that some MTAs re-order + header fields after they receive an email. If an MTA does change the + order of header fields with the same header field name (and those + header fields will be included in the hash calculation) then it is their + responsibility to recover the original order + before verifying an existing signature or passing a previously signed + message to another MTA that may wish to do such verification. + +* The hash(es) of the concatenated header fields are calculated. + +The name of the hash and the hash value +(converted to base64 form) is then inserted into (Signers) or compared +to (Verifiers) the value of the "h=" tag of the Message-Instance header +field that is being created/verified. If multiple hashes are calculated +then multiple entries within the "h=" value will be inserted/compared. + +# The Message-Instance Header Field {#hfMessageInstance} + +A Message-Instance header field documents the current contents of +the message and, in the case of a Reviser, records any relevant +changes that have been made to the incoming message. + +The Message-Instance header field is a list of tag values as described +below. The m= and h= tags MUST be present. The r= tag is optional. + +The tag identifiers (before the = sign) MUST be treated as case +insignificant, the tag value (after the = sign) is case significant. The +tags may appear in any order, but MUST be only one of each kind. Unknown +tags, for extensions, MUST be ignored. + +ABNF: + + mi-field = "Message-Instance:" mi-tag-list + mi-tag-list = *([FWS] mi-tag [FWS] ";" [FWS]) + mi-tag = mi-m-tag / mi-h-tag / mi-r-tag / x-tag + x-tag = ALPHA *(ALPHA / DIGIT / "_") "=" %x21-3A / %x3C-7E + ; for extension + +## m= the revision number of the Message-Instance header field + +The Originator of a message uses the +value 1. Further Message-Instance header fields are added with a value one +more than the current highest numbered Message-Instance header field. Gaps +in the numbering MUST be treated as making the whole message impossible +to verify. + +ABNF: + + mi-m-tag = %x6d [FWS] "=" [FWS] 1*DIGIT + +## r= recipes to recreate the previous instance of the message + +The r= tag value is the base64 encoded version of the JSON object that +contains the recipes that allow the previous instance of the message +to be recreated (see {{JSONrecipe}}}. + +ABNF: + + mi-r-tag = %x72 [FWS] "=" base64string + +## h= the hash values for the message + +The h= tag value contains the hash name, header hash value and body +hash value. Calculating the hash values is explained in {{messagehashes}}. + +ABNF: + + mi-h-tag = %x68 [FWS] "=" hash-set *("," hash-set ) + hash-set = [FWS] hash-name [FWS] ":" header-hash ":" body-hash + hash-name = "sha256" / x-hash-name + header-hash = base64string + body-hash = base64string + x-hash-name = textstring ; for later expansion + +# The DKIM2-Signature Header Field {#hfDKIM2signature} + +The signature of the email is stored in a DKIM2-Signature header +field. This header field contains tag values that provide the +signature and key-fetching data. The i=, m=, t=, mf=, rt=, d= +and s= tags MUST be present. The other tags are optional. + +The tag identifiers (before the = sign) MUST be treated as case +insignificant, the tag value (after the = sign) is case significant. The +tags may appear in any order, but MUST be only one of each kind. Unknown +tags, for extensions, MUST be ignored. + +ABNF: + + sig-field = "DKIM2-Signature:" sig-tag-list + sig-tag-list = *([FWS] sig-tag [FWS] ";" [FWS]) + sig-tag = sig-i-tag / sig-m-tag / sig-t-tag / sig-mf-tag / + sig-rt-tag / sig-d-tag / sig-s-tag / sig-n-tag / + sig-f-tag / x-tag + +It will be noted that we have not included a version number. Experience +from IMF onwards shows that it is essentially impossible to change +version numbers. If it becomes necessary to change DKIM2 in the sort +of incompatible way that a v=2 / v=3 version number would support, +it is expected that header fields will be labelled as DKIM3 instead. + +## i= the sequence number of the DKIM2-Signature header field + +The Originator of a message uses the +value 1. Further DKIM2-Signature header fields are added with a value one +more than the current highest numbered DKIM2-Signature header field. Gaps +in the numbering MUST be treated as making the whole message unsigned. + +ABNF: + + sig-i-tag = %x69 [FWS] "=" [FWS] 1*DIGIT + +## m= the highest numbered Message-Instance header field + +This value allows verifiers to determine which entity made a particular +revision to the message header fields or body. + +ABNF: + + sig-m-tag = %x6d [FWS] "=" [FWS] 1*DIGIT + +## n= nonce value + +This text value, if present, has a meaning to the creator of the signature +but MUST NOT be assumed to have any meaning to any other entity. It +MAY be used as an index into a database to assist in handling Delivery +Status Notifications or for any other purpose. + +To discourage use of this tag field as an alternative to the use of more +appropriate header fields, the length of the string MUST NOT +exceed 64 characters and implementations SHOULD reject messages +where this limit has been ignored. + +Note the value MUST be simple ASCII and MUST NOT contain semicolon. + +ABNF: + + sig-n-tag = %x6e [FWS] "=" [FWS] nonce-value + nonce-value = *64(%x21-3A / %x3C-7E) + ; printable ASCII except semicolon, max 64 chars + +## t= signature timestamp + +The time that this header field was created. The format is the number of +seconds since 00:00:00 on January 1, 1970 in the UTC time zone. The value +is expressed as an unsigned integer in decimal ASCII. This value +is not constrained to fit into a 31- or 32-bit integer. + +Implementations SHOULD be prepared to handle values up to at least +10^12 (until approximately AD 200,000; this fits into 40 bits). + +Implementations MAY ignore signatures that have a timestamp in the future. +Implementations MAY ignore signatures that are more than 14 days old. + +ABNF: + + sig-t-tag = %x74 [FWS] "=" [FWS] 1*DIGIT + +## mf= the MAIL FROM used when the message was sent + +DKIM2 records the [RFC5321] MAIL FROM value that was used when the message +was transmitted over an SMTP link from the signing MTA. Note that MAIL FROM +may be just "<>", for example for a Delivery Status Notification. + +The value is recorded as the base64 encoding of the [RFC5321] reverse-path +because of the complex syntax of reverse-path values (which can include +characters which would confuse naive parsers of DKIM2-Signature header +fields). The angle brackets MUST be included, but any "Mail-parameters" +that were present after the reverse-path MUST NOT be included. + +ABNF: + + sig-mf-tag = %x6d %x66 [FWS] "=" base64string + +## rt= the RCPT TO value(s) used when the message was sent + +DKIM2 records the [RFC5321] RCPT TO value(s) that were used when the message +was transmitted over an SMTP link from the signing MTA. + +The value is recorded as the base64 encoding of the [RFC5321] Forward-path +because of the complex syntax of Forward-path values (which can include +characters which would confuse naive parsers of DKIM2-Signature header +fields). The angle brackets MUST be included, but any "Rcpt-parameters" +that were present after the Forward-path MUST NOT be included. + +When a message is intended for more than one recipient then the RCPT +TO values provided MAY include all of the recipients so that a single +copy of the email MAY be sent to all of the recipients in a single SMTP +transaction. Alternatively, multiple copies of the email may be +generated so as to not immediately reveal who else received the email. + +However, if "bcc:" recipients are involved then in order to +meet the requirements of [RFC5322] Section 3.6.3 each and every +bcc recipients MUST NOT revealed to any other message recipient. + +ABNF: + + sig-rt-tag = %x72 %x74 [FWS] "=" base64string *("," base64string) + + +## d= the domain associated with this signature. + +This domain is used to form the query for the public key. The domain MUST be a valid DNS +name under which the DKIM2 key record is published. + +The domain name in the d= tag MUST exactly match the rightmost labels of +the domain name of the mf= tag. That is to say, the domain name of the +mf= tag MUST either match the d= domain exactly or be a sub-domain +of the d= domain name. + +When the mf= domain is empty ("<>"), as will be the case for Delivery +Status Notifications (DSNs), then no match is required. + +ABNF: + + sig-d-tag = %x64 [FWS] "=" [FWS] Domain + +## s= the signature value(s) for the message + +The s= tag value contains the selector, signature algorithm name and +signature value. Calculating the value is explained in +{{calculate-signature}}. + +The selector values subdivides the namespace for the domain being +used for signing. + +The algorithm value is the one used to generate +the signature. Verifiers MUST support "RSA-SHA256" for which +the string "rsa-sha256" is used and "Ed25519-SHA256" for which the +string "ed25519-sha256" is used. See {{algorithms}} for a description +of these algorithms. + +To provide for algorithmic dexterity more than one signature, +using different algorithms, MAY be supplied. Since the DNS lookup for +the public key will check that the k= algorithm value matches, a different +selector MUST necessarily be used for each signature. + +ABNF: + + sig-s-tag = %x73 [FWS] "=" [FWS] sig-set *( "," sig-set ) + sig-set = selector [FWS] ":" [FWS] sig-name [FWS] ":" message-sig + sig-name = "rsa-sha256" / "ed25519-sha256" / x-sig-name + x-sig-name = textstring ; for later extension + message-sig = base64string + +## f= flags + +Flags serve two purposes; they either report what has been done to +the message by the system creating the DKIM2-Signature or they make +a request to systems that handle the mail thereafter. Flags are +separated by commas, and optional white-space allows systems to +add several flags without creating long lines. + +If a flag value is not recognised it MUST be ignored. + +The flag values that report things are: + +"exploded": this message (identified by its unique header hash value (recorded +in the h= JSON object of the relevant Message-Instance) is being sent to more +than one email address. An +MTA which receives a message MAY use this information to help it distinguish +between malicious "DKIM replay" and legitimate activity performed by +mailing list. If this flag is not present in at least one DKIM2-Signature +header field then an MTA MAY assume that only one copy of a particular +message (identified by relevant cryptographic hash values) is intended +to exist; + +The flags values that make requests are: + +"donotexplode": this Signer requests that the message not be sent to more +than one recipient. A system that, by local policy, ignores this request +MUST NOT allow any of the copies it creates to be forwarded on to any +MTA outside its control. + +"donotmodify": this Signer requests that the message not be modified from +the form in which it is sent. A system that, by local policy, ignores this +request MUST NOT allow the message to be forwarded on to any +MTA outside its control. + +"feedback": this Signer requests feedback about how this message is handled +during delivery and thereafter. This document does not describe what such +feedback might be or where it might be delivered. If this flag is absent +then feedback is explicitly not required. + +ABNF: + + sig-f-tag = %x66 [FWS] "=" [FWS] sig-f-tag-data + *( [FWS] "," [FWS] sig-f-tag-data) + sig-f-tag-data = "donotmodify" | "donotexplode" | "feedback" | + "exploded" | x-sig-f-tag-data + x-sig-f-tag-data = textstring ; for later extension + +# Signer Actions + +This section gives the actions that need to be undertaken by the signer +of a message. They may be done in any appropriate order. + +## Add any Necessary Message-Instance Header Fields + +If a system is generating the initial form of a message or if +it is a Reviser that has made changes to the message body and/or +header fields then it MUST compute the body hash as described in +{{computing-body-hash}} and the hash of the header fields +as described in {{computing-header-hash}}. + +If the message does not contain a Message-Instance header field then one +MUST be added. + +If hashing the message body or relevant header fields does not +give the same hash values as those recorded in the highest version +(m=) Message-Instance header field then a new Message-Instance +header field MUST be added and if they are the same a new +Message-Instance header field SHOULD NOT be added. + +A Message-Instance header field MUST contain "recipes" to be able to +recreate the message corresponding to the hash values in the +currently highest numbered Message-Instance header field, or a +null recipe to indicate that recreating the previous version +of the message will not be possible. + +A system may add more than one Message-Instance header field if it +wishes to do so, but the DKIM2 design allows all modifications made by +any single system to be documented +in a single Message-Instance header field. + +Note that the first (m=1) Message-Instance header field MAY +contain "recipes" if it is wished to record any changes made to a +message as it enters the DKIM2 ecosystem. All other Message-Instance +header fields SHOULD contain at least one "recipe". + +## Provide a "Chain of Custody" for the Message {#chain-of-custody} + +The DKIM2-Signature header field contains the MAIL FROM +and RCPT TO values that will be used when the message is transmitted, +so these [RFC5321] "envelope" values MUST be available to (or +deducible by) a Signer. + +The receiver of a message will check for an exact match (including +the local parts of the email addresses) between the MAIL FROM / RCPT TO +[RFC5321] protocol values and the mf= and rt= values in the highest numbered +(most recent) DKIM2-Signature header field. It is acceptable for there to +be more RCPT TO email addresses recorded in rt= than are actually used in +the SMTP conversation, but any RCPT TO value which is used MUST be present. + +Verifiers will check for a relaxed domain match (see {{relaxed-domain-match}}) +between the signing domain (d=) and the domain in the MAIL FROM value. + +When the message being signed already has a DKIM2-Signature header field +(i.e. it has already been transmitted at least once) then a valid +"chain of custody" MUST be apparent when all of the DKIM2-Signature header fields +are considered. This "chain of custody" contributes to the way in +which DKIM2 tackles "DKIM replay" attacks. + +In any situation where a message will be forwarded in such a way that the +mf= on the outgoing message is such that the "chain of custody" +would be broken then the Signer MUST generate an extra DKIM2-Signature +header field that causes values to match, i.e. a record must be fabricated +that documents the mail being passed from one domain to another. + +It will be noted that the +creation of this extra header field will require the Signer to have access +to a DKIM2 private key associated with a domain in the RCPT TO entry. This is +often achieved by the Signer creating the private key and never sharing it and +then taking one of two approaches to publishing the public key. +The first is to provide the public key (and selector value) to the domain owner +who creates an appropriate DNS entry. The alternative is for the Signer +to create a public +key DNS entry within a part of the DNS that they control and the domain owner +publishes a CNAME pointing at this. + +If an MTA does not change anything in the message which would require +a new Message-Instance header field and it is going to send it onwards +to a system that be able to verify the existing message (that is no +changes are made to the MAIL FROM and RCPT TO values) and there is no +other reason to add a DKIM2-Signature header field then the MTA MAY +choose not to add one. This means that an essentially transparent +SMTP forwarding system need not be made "DKIM2 aware". + +## The Relaxed Domain Match Algorithm {#relaxed-domain-match} + +To assist in addressing the "DKIM replay" problem DKIM2 provides a +"chain of custody" for every message. This is established by checking +that the MAIL FROM value recorded in every DKIM2-Signature header field +(except of course the i=1 instance) can be matched with a RCPT TO value +of the next lower numbered DKIM2-Signature header field. + +It is also necessary to check DKIM2-Signature header fields for a match +between the signing domain (specified in the d= tag) and the MAIL FROM +domain. + +To allow systems to use existing "bounce-handling" schemes with special +subdomains in their MAIL FROM values a "relaxed" approach is taken +to the matches between these values. + +* Only the domain part of the MAIL FROM and RCPT TO values is used + for these matches The local part (and the @) are ignored. + +* If there is not an exact match between the domain names then labels + are removed, one by one from the left hand side of the MAIL + FROM domain name and the comparison is repeated. + +* If no labels remain then there is no match. + +## Select a Private Key and Corresponding Selector Value {#signer_privatekey} + +This specification does not define the basis by which a Signer should +choose which private key and selector value to use -- this will be a +matter of administrative convenience. Distribution and management of private +keys is also outside the scope of this document. + +## Calculate a Signature Value {#calculate-signature} + +A Signer calculates a signature solely over the Message-Instance and +DKIM2-Signature header fields of the message. The hashes of +the body and other header fields are covered by the hashes in +the highest version (m=) Message-Instance header field and hence +the signature will in practice be signing the message as a whole. + +Most cryptographic schemes proceed by first calculating a hash value +and then signing the hash value, but the DKIM2-Signature header field +only provides the final signature value. This means that there +is no difficulty if the hash value is inordinately long, or is +not emitted by the cryptographic routine being used. + +The signature algorithm MUST apply the following steps +in the order given (which are not quite the same as the steps +undertaken in calculating header hashes). + +* Convert all relevant header field names (not the header field values) to + lowercase. For example, convert "DKIM2-signature" to "dkim2-signature". + +* Unfold all header field continuation lines as described in + [RFC5322]; in particular, lines with terminators embedded in + continued header field values (that is, CRLF sequences followed by + WSP) MUST be interpreted without the CRLF. Implementations MUST + NOT remove the CRLF at the end of the header field value. + +* Delete all WSP characters. This means all WSP characters before and + after the colon separating the header field name from the header + field value, all WSP characters within the unfolded header field + value and all trailing WPS characters before the CRLF. The colon + separator and the CRLF MUST be retained. + +* Place the header fields in order. First come the Message-Instance + header fields in ascending instance (m=) order. Second are the + DKIM2-Signature header fields in ascending sequence (i=) order. + Last of all is an incomplete DKIM2-Signature header field (the + one that this system is creating) with all tags present except + that the signature value(s) within the (s=) value are set to + the null string (""). The incomplete header field MUST be + unfolded, MUST have a trailing CRLF and MUST have spaces removed + in just the same way as the + complete header fields being processed. + +* The concatenated header fields are then fed to the signature + algorithm(s). Once all the values are available the null + signature value strings + are replaced by the base64 values of the signatures. + +# Verification Requirements + +The details of verification appear in {{verifier_actions}} below. +This section considers when verification should be performed and +how thorough it needs to be. + +## Check Most Recent Signature and Hashes for the Message + +A Verifier SHOULD check the validity of the most recently applied +(highest numbered i= value) DKIM2-Signature header field +and the associated (m=) Message-Instance before accepting an email. + +If these checks +do not pass then a Delivery Status Notification (DSN) for the email MUST +NOT be generated thereafter -- hence the best strategy, if the email +is not wanted, is to reject it (with a 5xx error code) whilst the +relevant SMTP conversation is still ongoing. If the check gives +a TEMPFAIL result then a 4xx error code SHOULD be used to allow the +sending MTA to understand the situation. + +If the checks do pass and it is later determined that the email +is unacceptable for any reason then a DSN MAY be created and +passed to the system that delivered the email. The details of +this procedure appear in {{bounce}}. + +## Checking the Message-Instance Header Fields + +If the message has been modified since its original creation then +the Message-Instance header fields will enable a Verifier to determine +whether or not all the changes made are correctly recorded +by using the "recipes" to construct each preceding version +of the message. + +Note that if it is only the first form of the message is of +interest then all the "recipes" can be applied in turn and +only one hash value checked -- the correctness of the +intermediate hash values are not relevant to this assessment. + +## Checking the DKIM2-Signature Header Fields + +However, in order to check the chain of custody, to assess +whether the message has been exploded, to pick out +"feedback" requests to be honoured or to assign reputation to +Revisers then all of the DKIM2-Signature header fields +will have to checked for validity. The TBA document explores +these issues in more detail. + +## Interpret Results/Apply Local Policy {#verifier_interpret} + +It is beyond the scope of this specification to describe what actions +the recipient of an email performs, but mail carrying valid DKIM2 +signatures gives the recipient opportunities that unauthenticated +email would not. Specifically, an authenticated email provides +predictable information by which other decisions can reliably be +managed, such as trust and reputation. Conversely, it is hard +to assign trust or reputation to unauthenticated email. + +If an MTA wishes to reject messages where signatures are missing +or do not verify, the handling MTA +SHOULD use a 550/5.7.x reply code. + +Where the Verifier is integrated within the MTA and it is not +possible to fetch the public key, perhaps because the key server is +not available, a temporary failure message MAY be generated using a +451/4.7.5 reply code. + +Temporary failures such as inability to access the key server or +other external service are the only conditions that SHOULD use a 4xx +SMTP reply code. In particular, cryptographic signature verification +failures MUST NOT provoke 4xx SMTP replies. + +# Verifier Actions {#verifier_actions} + +This section discusses the detail of the actions taken by a +Verifier. In essence +this will involve repeating all the actions taken by a Signer to +produce a Message-Instance or DKIM2-Signature header field. To +avoid a lot of repetition these actions will not be spelled out +in detail. Once a hash value has been calculated it is then +compared with the value reported by the Signer, or the Signer's +public key is used to determine whether a signature that has +been provided is correct. + +When a Verifier is determining whether a particular DKIM2-Signature +header field it MUST consider the state of the message when that +header field was added to the message. That means it MUST first apply +all relevant recipes to reconstruct the body and header fields and it +MUST ignore any Message-Instance and DKIM2-Signature fields that +were added after that point. + +## Output States + +For compatibility with the Authentication-Results header field defined +in [RFC8601] a verification will result in one of four states: + +PASS: The message was successfully verified. + +FAIL: The message could be verified but a hash or signature was not + correct. + +PERMERROR: The message could not be verified due to some error that + is unrecoverable, such as a required header field being absent + or malformed. + +TEMPERROR: The message could not be verified due a temporary + inability to retrieve a public key. A later attempt may + produce a different. + +A Verifier MAY cease verifying once a single failure is detected. + +Verifiers wishing to communicate the results of verification to other +parts of the mail system may do so in whatever manner they see fit. If +they wish to provide a human-readable string to describe a failure +to verify (any state except PASS) then in order to provide the +maximum possible assistance to senders they SHOULD use the text +strings specified in this document. These human-readable messages +are described with m=`` or tag=`` placeholders, the `` and `` MUST +be replaced with the relevant ordinal or tag name (without the < and +> characters). Similarly `` MUST be replaced by a relevant +string for the particular message. + +If the verification is being performed during an SMTP protocol +conversation the human-readable string SHOULD be part of the +5xx or 4xx response string. + +If the results of the verification are being communicated in a +Delivery Status Notification message ({{RFC3461}}) the +human-readable string should be included. + +If, by local policy, a system wishes to accept a message which +has failed authentication it might choose to add an email header +field to the message before passing it on. Any such header field +SHOULD include the human-readable string and +SHOULD be inserted before any existing DKIM2-Signature or pre-existing +authentication status header fields in the header field block. The +Authentication-Results: header field ([RFC8601]) MAY be used for this +purpose. It should be noted that any "Authentication-Results" header +field will count as a modification to the email if any further +DKIM2-Signature header fields are to be generated. + +## Ensure that the DKIM2 Header Fields are Valid + +Verifiers MUST meticulously validate the format and values of all +relevant Message-Instance and DKIM2-Signature header fields. It MUST +also ensure that all required instances of these header fields are +present and that all required tags are present. Recall however +that unknown tags MUST be ignored. + +As a special case, there MUST not be a Message-Instance field +with a higher m= value than occurs in any DKIM2-Signature field. + +Possible errors: + + PERMERROR Message-Instance m= missing + PERMERROR Message-Instance m= syntax error + PERMERROR Message-Instance m= tag= missing + PERMERROR Message-Instance m= is not signed + PERMERROR DKIM2-Signature i= missing + PERMERROR DKIM2-Signature i= syntax error + PERMERROR DKIM2-Signature i= tag= missing + +## Check the timestamps + +Verifiers SHOULD return a failure it is more than 14 days since the +timestamp recorded in the "t=" tag of any DKIM2-Signature header field. + +Possible errors: + + PERMERROR DKIM2-Signature i= signature expired + +## Check the Chain-of-Custody + +As explained in {{chain-of-custody}} a Verifier MUST check an exact +match between the MAIL FROM and RCPT TO parameters used when delivering +a message and the values found in the mf= and rt= tags of the highest +numbered DKIM2-Signature header field. There may be extra values +in the rt= value, but all RCPT TO values actually used for +delivery MUST be present. + +The values of domains MUST BE put into lower-case before doing these +checks. As is usual in email protocols the case of the local part of +an email address is assumed to matter. Note that these checks MUST NOT +use the relaxed domain match algorithm. + +A Verifier SHOULD check that there is a relaxed domain match +(see {relaxed-domain-match}) between the signing domain of the +most recently applied DKIM2-Signature header field and the +mf= value in that header field. + +Possible errors: + + PERMERROR: MAIL FROM did not match + PERMERROR: RCPT TO did not match + PERMERROR: MAIL FROM and d= do not match + +## Fetch the Public Key + +The public keys of all the signatures in DKIM2-Signature fields are +needed to complete the verification process. Details of key management and +representation are described in {{key_management}} and [DKIMKEYS]. +The Verifier MUST validate the key record and MUST not use any public +key records that are malformed. + +Note that DNS timeouts MUST be reported as TEMPERROR but a DNS +result that indicates the key is absent MUST be reported as a +PERMERROR. Additionally, as [DKIMKEYS] makes clear, if more than +one record is returned this is an error. The human-readable error +message SHOULD provide the selector value so that it is clear which +key has caused a problem. + +Note that [DKIMKEYS] has retired the h= field and DKIM2 implementations +MUST ignore this tag if it is present. + +Possible errors: + + TEMPERROR: DKIM2-Signature i= public key could not be fetched + PERMERROR: DKIM2-Signature i= public key does not exist + PERMERROR: DKIM2-Signature i= public key has multiple records + PERMERROR: DKIM2-Signature i= public key has a syntax error + PERMERROR: DKIM2-Signature i= public key algorithm mismatch + PERMERROR: DKIM2-Signature i= public key has been revoked + +## Perform the Signature Verification Calculation + +Verifying a signature consists of actions semantically equivalent to the +following steps: + +1. Prepare a canonicalized version of the Message-Instance and DKIM2-Signature + header fields as described in {{calculate-signature}}. The signature value(s) + themselves will need to be removed to correspond with what was actually + signed. Note that this canonicalized version does not actually replace + the original content. + +1. Use the relevant public key value(s) to check the signature(s). + +1. If there is more than one signature provided then they MUST all be + checked if the Verifier is able to do so. If any signature fails then + an error SHOULD be reported. If all signatures that can be checked fail + then PERMFAIL MUST be reported. + +1. If some signatures fail and other pass then any error that is + reported should provide that information (e.g. PERMFAIL "rsa-sha256 + signature passed, ed25519-sha256 signature failed"). + +The reasoning for requiring that all signatures pass is that if a signature +scheme has recently become deprecated because it is known to be cryptographically +flawed then Signers will use a second (unbroken) signature scheme. However, such +a Signer may still provide the other signature for the benefit of Verifiers +that have yet to upgrade -- reasoning perhaps that attacks are too expensive +to be a very significant security issue. A Verifier that determines that +one signature passes whilst the other fails may well be in a position to +prevent an attack. + +Possible errors: + + FAIL: DKIM2-Signature i= public key incorrect signature + +## Validating Body and Header hashes + +Verifying a hash value requires a Verifier to repeat the hash calculation +performed by the Signer as set out in {{computing-body-hash}} +and {{computing-body-hash}}. The values can then be directly compared. + +Since there may be more than one hash algorithm given the human-readable +error message SHOULD indicate which algorithm's result failed to match. + +Possible errors: + + FAIL: Message Instance m= header hash mismatch + FAIL: Message Instance m= body hash mismatch + +# Delivery Status Notifications in the DKIM2 ecosystem {#bounce} + +In the DKIM2 ecosystem, when a message cannot be delivered then +this is reported to the sending machine by means of an {{RFC5321}} +return code or, if the SMTP session has completed, by generating +a Delivery Status Notification (DSN, as defined in {{RFC3461}}. + +A DSN MUST be addressed to the MTA that sent the message. This +prevents "backscatter" by passing failures back along the chain +of MTAs that were in involved in passing the message forwards. This +is achieved by using the mf= tag from the highest numbered +DKIM2-Signature field. If this field is null ("mf=<>") then a DSN +MUST NOT be sent. + +## DSN contents + +As set out in {{RFC3461}}, the DSN has a top-level MIME part of +type `multipart/report`. Among other things, that MIME part must +contain a MIME part of type `message/rfc822` that holds either +the original message exactly as it was submitted by the sending system +or just the header fields of that message. + +All relevant DKIM2-Signature header fields (and Message-Instance +header fields if the message body is supplied) MUST verify. The +DSN itself MUST have appropriate Message-Instance and DKIM2-Signature +fields, noting that the MAIL FROM to be used will be null ("<>"). + +If the message body has been truncated (rather than omitted +altogether) then in order to allow verification of the DNS +contents a Message-Instance header field MUST be added to the +message with a body recipe containing a {"z": true} step. + +### Bounce Propagation + +A Forwarder which receives a DSN MAY decide to propagate this +DSN to the MAIL FROM address used to deliver the message to it +(which can be found in the relevant DKIM2-Signature header field). +The DSN SHOULD be handled in the usual way, with Message-Instance +header fields documenting any changes and a DKIM2-Signature +field with an incremented hop count value added. + +The Forwarder MAY alternatively decide to reconstruct the message +(or just the message header fields) as they were when the message +was delivered to the Forwarder and construct a DSN using that +information. The information in Message-Instance header fields +can be used to achieve this. The resultant DSN is sent to the +MAIL FROM address from the now highest numbered DKIM2-Signature +header field. Doing this will ensure that details of where the +message was forwarded to will not be revealed to the previous hop. + +### Authentication of Inbound Bounce Notifications + +When a system receives a DKIM2 signed bounce notification, and the +included original message is also DKIM2 signed, it SHOULD +verify that this message (or just the header fields if the body +is not present) has not been altered. + +This means: + +1. The DSN's DKIM2-Signature will have a signing domain that is +aligned with the recipient of the message that is being returned. +The recipient's address is located in the rt= tag of the +last (highest i= tag) DKIM2-Signature in the returned message. + +1. The last (highest `i=` tag) DKIM2-Signature header field of the +returned message will be one that was generated by the system +receiving the bounce notification, determined by examining the +d= and mf= tags of that DKIM2-Signature header field. + +1. The header fields of the embedded message (in the message/rfc822 +MIME part) can be verified. If the message body is present then +that can also be verified by inspecting the Message-Instance +header field(s). + +If the verification fails then the DSN MUST NOT be propagated +any further. If verification has been performed prior to +accepting the DSN from the sender the DSN SHOULD be rejected +with a 550/5.7.x return code. If the verification cannot be completed +because of a temporary issue (with DNS lookups) then a 4xx +return code should be used. + +# Preventing Transport Conversions {#signer_normalize} + +DKIM2's design is predicated on valid input. + +In order to be signed a message will need to be in "network normal" format +(text is ASCII encoded, lines are separated with CRLF characters, etc.). + +A message that is not compliant with [RFC5322], [RFC2045], [RFC2047] +and other relevant message format standards can be subject to attempts +by intermediaries to correct or interpret such content. See Section 8 +of [RFC6409] for examples of changes that are commonly made. Such +"corrections" may invalidate DKIM2 signatures or have other undesirable +effects, including some that involve changes to the way a message is +presented to an end user. + +When calculating the hash on messages that will be transmitted using +base64 or quoted-printable encoding, Signers MUST compute the hash +after the encoding. Likewise, the Verifier MUST incorporate the +values into the hash before decoding the base64 or quoted-printable +text. However, the hash MUST be computed before transport-level +encodings such as SMTP "dot-stuffing" (the modification of lines +beginning with a "." to avoid confusion with the SMTP end-of-message +marker, as specified in [RFC5321]). + +Further, if the message contains local encoding that will be modified before transmission, +that modification to canonical [RFC5322] form MUST be done before signing. +In particular, bare CR or LF characters (used by some systems as a local line +separator convention) MUST be converted to the SMTP-standard CRLF +sequence before the message is signed. Any conversion of this sort +SHOULD be applied to the message actually sent to the recipient(s), +not just to the version presented to the signing algorithm. + +More generally, the Signer MUST sign the message as it is expected to +be received by the Verifier rather than in some local or internal form. + +# EAI ([RFC6530]) Considerations for DKIM2 + + TBA + +# IANA Considerations + + TBA + +# Security Considerations + + TBA + +Changes from Earlier Versions +============================= + +draft-ietf-dkim-dkim2-spec-01 + +Additions to terninology. Improved ABNF. Removed definition of tag-list +and placed relevant text in the two header field definitions. Untangled +he description of what needs to be verified from the description of how +to verify and provided a list of human-readable strings to generate for +errors. + +draft-ietf-dkim-dkim2-spec-00 + +Removed JSON for hashes, signatures and SMTP parameters. Provided +valid JSON for recipes and added "z" for truncated body. +Changed algorithm names for signing. Simplified the canonicalisation +performed for the header fields signed by DKIM2-Signature. +Changed v= to m= for message instance numbering. + +General tidying up of specifying tag=value specifications and +associated ABNF. Various other fixes for issues flagged in WG. + +\[\[This section to be removed by RFC Editor\]\] + \ No newline at end of file diff --git a/stevea/tag.go b/stevea/tag.go new file mode 100644 index 0000000..4708535 --- /dev/null +++ b/stevea/tag.go @@ -0,0 +1,139 @@ +package dkim2 + +import ( + "fmt" + "strconv" + "strings" +) + +type TagValue struct { + Name string + Value string + OriginalName string +} + +type Tags struct { + Tags []TagValue +} + +// NewTags returns the semicolon separated key=value fields from +// an email header. +func NewTags(name string, h string, idxTag string) (Tags, error) { + var tags []TagValue + h = strings.ReplaceAll(h, "\r\n", "") + for kv := range strings.SplitAfterSeq(h, ";") { + k, v, found := strings.Cut(kv, "=") + if !found { + if strings.TrimSpace(kv) == "" { + continue + } + return Tags{}, ErrMalformedHeader{ + Name: name, + V: h, + Err: fmt.Errorf("missing '=' in %q", kv), + Idx: idxForError(tags, h, idxTag), + } + } + k = strings.TrimSpace(k) + var trailingSemicolon bool + v, trailingSemicolon = strings.CutSuffix(v, ";") + _ = trailingSemicolon + v = strings.TrimSpace(v) + tags = append(tags, TagValue{ + Name: strings.ToLower(k), + Value: v, + OriginalName: k, + }) + } + return Tags{Tags: tags}, nil +} + +func (t *Tags) Get(name string) (string, bool) { + name = strings.ToLower(name) + for _, tag := range t.Tags { + if tag.Name == name { + return tag.Value, true + } + } + return "", false +} + +func (t *Tags) Set(name string, value string) { + lowerName := strings.ToLower(name) + for i, tag := range t.Tags { + if tag.Name == lowerName { + t.Tags[i].Value = value + return + } + } + t.Tags = append(t.Tags, TagValue{ + Name: lowerName, + Value: value, + OriginalName: name, + }) +} + +func (t *Tags) String() string { + builder := strings.Builder{} + for _, tag := range t.Tags { + builder.WriteString(tag.Name) + builder.WriteRune('=') + builder.WriteString(tag.Value) + builder.WriteString("; ") + } + return builder.String() +} + +// idxForError retrieves the m= or i= field for use +// during error reporting. As we're reporting an error +// the header is likely to be malformed, so we do our +// best. Slow path. +func idxForError(tags []TagValue, h, tagName string) int { + if tagName == "" { + return 0 + } + + for _, tag := range tags { + if tag.Name == tagName { + idx, err := strconv.ParseInt(tag.Value, 10, 32) + if err == nil { + return int(idx) + } + } + } + + for _, kv := range strings.Split(h, ";") { + k, v, found := strings.Cut(kv, "=") + if found && strings.ToLower(strings.TrimSpace(k)) == tagName { + idx, err := strconv.ParseInt(v, 10, 32) + if err == nil { + return int(idx) + } + } + } + return 0 +} + +/* +func SortedTagHeaders(name string, headers []string, sortBy string) ([]Tags, error) { + result := make(map[int]Tags, len(headers)) + for _, h := range headers { + tags, err := NewTags(name, h) + if err != nil { + return nil, err + } + for _, tag := range tags.Tags { + if tag.Name == sortBy { + idx, err := strconv.ParseInt(tag.Value, 10, 32) + if err != nil { + return nil, ErrMalformedHeader{ + Name: name, + V: h, + Err: err, + } + } + } + } + } +} +*/ diff --git a/stevea/testdata/body/body.new b/stevea/testdata/body/body.new new file mode 100644 index 0000000..3907de8 --- /dev/null +++ b/stevea/testdata/body/body.new @@ -0,0 +1,5 @@ +According to all known laws of aviation, there is no way a bee should be able to fly. +Its wings are too small to get its fat little body off the ground. +The bee, of course, flies anyway because bees don't care what humans think is impossible. +Yellow, black. Yellow, black. Yellow, black. Yellow, black. +Ooh, black and yellow! \ No newline at end of file diff --git a/stevea/testdata/body/body.old b/stevea/testdata/body/body.old new file mode 100644 index 0000000..a1b48df --- /dev/null +++ b/stevea/testdata/body/body.old @@ -0,0 +1,5 @@ +According to all known laws of aviation, there is no way a bee should be able to fly. +Its wings are too small to get its fat little body off the ground. +The bee, of course, flies anyway because bees don't care what humans think is impossible. +Yellow, black. Yellow, black. Yellow, black. +Ooh, black and yellow! \ No newline at end of file diff --git a/stevea/testdata/body/body.want.json b/stevea/testdata/body/body.want.json new file mode 100644 index 0000000..d71e0f0 --- /dev/null +++ b/stevea/testdata/body/body.want.json @@ -0,0 +1,5 @@ +{ "b": [ + {"c": [1, 3]}, + {"d": ["Yellow, black. Yellow, black. Yellow, black."]}, + {"c": [5, 5]} +]} diff --git a/stevea/testdata/diff/deleted.new b/stevea/testdata/diff/deleted.new new file mode 100644 index 0000000..50bab4a --- /dev/null +++ b/stevea/testdata/diff/deleted.new @@ -0,0 +1,3 @@ +Comment: two +Subject: three +Subject: one \ No newline at end of file diff --git a/stevea/testdata/diff/deleted.old b/stevea/testdata/diff/deleted.old new file mode 100644 index 0000000..ad2b498 --- /dev/null +++ b/stevea/testdata/diff/deleted.old @@ -0,0 +1,4 @@ +Comment: two +Subject: three +Subject: four +Subject: one \ No newline at end of file diff --git a/stevea/testdata/diff/deleted.want.json b/stevea/testdata/diff/deleted.want.json new file mode 100644 index 0000000..4f6d91f --- /dev/null +++ b/stevea/testdata/diff/deleted.want.json @@ -0,0 +1,7 @@ +{ + "subject": [ + { "c": [1, 1]}, + { "d": ["four"]}, + { "c": [2, 2]} + ] +} \ No newline at end of file diff --git a/stevea/testdata/diff/ranges.new b/stevea/testdata/diff/ranges.new new file mode 100644 index 0000000..a3ff8e1 --- /dev/null +++ b/stevea/testdata/diff/ranges.new @@ -0,0 +1,6 @@ +Subject: zero +Subject: one +Subject: two +Subject: three +Subject: banana +Subject: four \ No newline at end of file diff --git a/stevea/testdata/diff/ranges.old b/stevea/testdata/diff/ranges.old new file mode 100644 index 0000000..de6e73e --- /dev/null +++ b/stevea/testdata/diff/ranges.old @@ -0,0 +1,5 @@ +Subject: one +Subject: two +Subject: three +Subject: four +Subject: five \ No newline at end of file diff --git a/stevea/testdata/diff/ranges.want.json b/stevea/testdata/diff/ranges.want.json new file mode 100644 index 0000000..1411c1a --- /dev/null +++ b/stevea/testdata/diff/ranges.want.json @@ -0,0 +1,7 @@ +{ + "subject": [ + { "d": ["five"]}, + { "c": [1, 1]}, + { "c": [3, 5]} + ] +} \ No newline at end of file diff --git a/stevea/testdata/diff/reorder.new b/stevea/testdata/diff/reorder.new new file mode 100644 index 0000000..50bab4a --- /dev/null +++ b/stevea/testdata/diff/reorder.new @@ -0,0 +1,3 @@ +Comment: two +Subject: three +Subject: one \ No newline at end of file diff --git a/stevea/testdata/diff/reorder.old b/stevea/testdata/diff/reorder.old new file mode 100644 index 0000000..a71480f --- /dev/null +++ b/stevea/testdata/diff/reorder.old @@ -0,0 +1,3 @@ +Subject: one +Comment: two +Subject: three diff --git a/stevea/testdata/diff/reorder.want.json b/stevea/testdata/diff/reorder.want.json new file mode 100644 index 0000000..560fe04 --- /dev/null +++ b/stevea/testdata/diff/reorder.want.json @@ -0,0 +1,6 @@ +{ + "subject": [ + {"c": [2, 2]}, + {"d": ["one"]} + ] +} \ No newline at end of file diff --git a/stevea/testdata/dns.json b/stevea/testdata/dns.json new file mode 100644 index 0000000..917210e --- /dev/null +++ b/stevea/testdata/dns.json @@ -0,0 +1,300 @@ +{ + "test1.dkim2.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=hwjviTXyzUXSCWayBqE17s/4NSynQKxw58jayHudRAI=" + ] + ], + "rsa1024._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIubB7x1q3rNGDWgObuKSOyYVtVKmcJpIvtWdRzg71iGRGqMdEE18GAOk+6j+GAcHTppkh4qR1d9vOl4S1L8ClAvSFUz0azi31fLQcMpZbagyseSq9FnF4nHL/7MAA2brAXkVCQ1rZLKNHMwkXGggkA9kg+LloNfSML+utkhN3gQIDAQAB" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRtvI17L4pHwF58KhyjiN7d74ZHDZia1IOzuXA6hygEuxt0+0Ey9PJvrDpKp/JsJIiJ0Ji8hrQfeMbAX5wHpz8GAkRlWOdorPuZiMZegTU9oD9nWRO/GcAu7Ub4V1pF6AwwfykCmzKbomX7jWa1y0oNgMHMUeZAi1XveQ6cfebJOwtgqWMOTSenY8+p8hU97YFxwKXO0FsAQYvNMMSZAXPM00V/ZaxiZ1UZUCMM/uesVkU7pIOzItGEjoWUrPkIos1GGf+2nBncqNgmivPkJPFeaJXOIL1iHqKJrSzZuTxCWPTQ+JVPyeAgDk0xyGK3RbiyItPjVZhBs7sZekNGVCwIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqiTwabnGlrGDoPlSHpfiWjsbwucsezwm/iU9bjloGqothOM7XNIrS1ub2f5BNz9yQjOhWGJ+fo8DOnF9YdUKXkBxuUdt49eyClLDaUG4Q35hJBWFF1MsmihtJpo6PzXGZYP/c4mPc2vXTPd3hbAqkftMgUCOCUIUyUEXhMl/R6/XkXATcyDId3TsSyQUJk3U+2r/wQJGz5JkOxyDX1NEawfh3GDuppCUFZFWnsrEvolBGDqZk8RG2FNmRysglRau4z9GG8jieXG4NjIT/yOh3pjbYGq4tgrMVZ7AcrIpCRbEJTUCExgrh3iQRXReVPy2qhcgY6BQLF2ahiTBUm2wAwIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr8xHmZoXE3P12DNh5jhFAJjtI9kosn3bISKWYNyn2PAn8E+Ik70iScLj8bFUNcjlLRtBDo5KZ323gdoYS3AUBSJlbkLJKCnQnH6pY+rawmt5kCNJm15DTFlOyZhaMWUilFyVzzGDqXf3d/VzFiX+13GnDdwR1QOnbOTMKx9Y0+nEhlnqRwv3YFjAO1aQOdFzguxMi5wiZQIFtmwwY8GgFIVrEqFq4UCU/hc/E2YcYjHv5zg2KR/zJivfXdLOceHqzJTYdOca/IDqfat2IgOooVVsfHZCCScOutZe9JwWYt98EOiFfvmLs3pvJnBLyGM2BOZUpJnkXSDCnTKxboRnqQIDAQAB" + ] + ] + }, + "test2.dkim2.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=U62P/YVixJeLoAzIy1dTXRufMnA5SjRvgQrcuFNf/mY=" + ] + ], + "rsa1024._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcSzEVS5IaMQZWJaqmA7dnD1fHuks1mqzY9RfQn9skWCYJZxHx0d45oSSrMt8lSKZiN4FLgbBl0jiLWXq+oPP3rUEhQrqElzyzo1Swn1Phsq45ij655pXFgZpfXvS95nP2GGDrQLcZhi5VNDg9ACoitB1CtxTipRXm8anlzLtg2QIDAQAB" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolQDA1kFcPW13OQNJv7zEGhf18S+PU8oGUOSScVYRJELDUxZzv0i1OsyNW5T2hZlmTDFRszoxstj1o8JBn9nYm6zfdvr4w8JS5SrAEx8MzI4N/SghA334hbXtXQZ3br179XVgTMGJL0OMWw2Qp0c9HQAtQNF3ckeMiPncWp8e58in5YCjvHhezl8/VGrBx+CsDKxT8JFs/0QluC6AFQuM9ZIsm3RPwf4BsPE0/ADpuA5GUUdYUzhNt2Uq9Wr82BJLt8cy1a9FVEGKxdMhgJ7Gx4hx8GpM/oaiQYMO9VmNZVz8n87BkNOnjlpYFCtfb9FH8/mYPwqaSa0DmcahfeHVQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3i4wXkWpIYib21p0CZx1dobNCYababIxIZAJO5SEGiK++C7jLgnpqg+lvTKS7eR5q1MO3ZCY19Bgm/PcigLvuLtMzap4yY+h9hnsQYzrdcAamzrpB3cjiNoCNhT0Zp7kRI6Rx0t2Uc91e0CvaFf8zAJIF4VUyQNXx9Gn/SEtNr0iQCNsPptGA1PUGUwDQUGze7fkXtnBOrgvNjILfnUC7MA6W+2+mCYtHzOkRB+t6SMutR2cDSXabjYBL5/1bweL6ABDouGgBnIj9LrY6RcbzrBpLuUAuXi71dLEHs0KW0UdImyUpE1i+thKqhNGaYnaL8KrTlDUC8g62T2kQNuHJQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDOUuL/rLDi7fohdOw/sk31eGe5CtX9UEw5pSQv/EyYwW9lxZqi9SwU8Of+z7uHLMFJi+YbYV25CYDUrDIE71WLKou3FL0WyH0U4DrZoR7CBnMjRz92Lqh+VV1PJz0t5mU8YD0O+JJ80jScKIIcC8r1qysQI9Y7EdIAWFZlYS97c6WhKVg94xeOAaRDnpbr80H29g9pqGs4Yk4Hc1r5OXptj12sBMO7JCz/4dQ2Di0JsPOwEjWNbV9ysz8EcSW/+RoFG5Iomf4/q/aW7T6tUGqdj8M0eQ0TO0xW0lc4jqKUHH85LbdZFhcDIBUg8ML6mRgSVy779MxMP7+uw3iVLQQIDAQAB" + ] + ] + }, + "test3.dkim2.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=reUWo3pXLWHk5dILIK4NoCR3F2iACFdQ/FlhvVvMtxc=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAju2t0p4xwgCT4UCRgfNZ27U6nhQ5sHSV23hR2TBngda8yAChPInsdVyjJv+cSeZf7tG7yKrzKTM9KaKK8BzBguYrJ9DRJqg7MPPsXlZJ53Ydku3GKLcuiBmDrwUxyBGAMFxndVs+uNJkF2qi+RK0Dgd45wMZiJJF1K3bPjkkQub4Ex4MXvbIqqThthlYiKUGHFBPKg7DALkdoIlegrAP4xZ43Cszd5u9AvNdjvAr31ajjaGrGuQH+gW5kXdwZpDiQgvi0Obnr7AZeVSyr4I5CTNLoj4ed4I0AOJ3TsoZM1fFzHr/rqhL+oEKW3tA7UYGaRoXFDei0qLXhRGTJjzscQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAynXlN4POdoj82RpdpD7iG8RR/RF1TjQxy65Fu/12cV0YYX1mZLOHcvSUkxkxMKSxdZnv/7UKuQcbdQXC7jBah/JQNYzLVDUe3bKbrjsypczRPYKajxnEYCsjvoKDR9g2XtlppGrchst77wcj3SWplz2MGBk5E2SGVo1TuvuRt2S0iiye8+Z2KBaVUE3t55YxRHhjIfudboyq4Vqt6o1/6gl7eieZjqfqIBcU8k1xgEG5EG5GYCV12cUzvFU4Q5jPIzDWydppSN+jsIdSRbA8E0GweeRYumuNHryfDexZ04GafvjDwC+b9PCD5r8xiyt7N8gPNG052smGeK39N9Y/TQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmJR1PxhxeO2eZlHPZfikAOyn/95rjszoGWRZti+0VyitapUVvla9noM3w0rbFEbVwW4Y3ILqY7j1C1jiM02okbYNYwE0CC1WSTGUrSoyRV7nGFJ5n6vcLCLqE9EFJwFnUCCXDTz+90D4aiXgasm/MAJJMkBQzdrrpQTwnLGVfWYGenqUWJ1+yn8kXmDq/wub0oE5G3DEE7noCgxpzkEd6tqCIJ3Z1wcA9qnUsTBjmDLPEZAwc4ajwZ/cfXceDXnprUKlFvq9tfMReKfObT2g6/iBsesBLCsuYgHKRydNqT1+YU0GmkSQkXutgyH5o4WzkUsPim2saIiTkVPTCtbWBwIDAQAB" + ] + ] + }, + "test4.dkim2.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=fJAwKEPblCYdjrEIPeyOFy6AeXZUBALBdGQRjSPe97c=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqxfEjz2MPZ7ZiE/uQkgPOC7mn0CweB5MZgHMqgGPQodj2DpbILnxBivC64VV5/JItBaNCtEL3UFY1YzlJOKzqjJacF66u9en4m6L6uC31vrHmVME6+rx7B5nMBlkwPheamx8Dyf+wNo3/9UCKxSdFdtiJLpLGC7Tg2ry7tpxST4Joaf9fIggc3Zmaraidk0S0uJKQq6ZKoZtjJkt0Bd+LGEnGC6C9/lrjHarnImc1bcELpJrzmneOmJO1/b4C8TXawu7luKn6dTWhujAOam+sO4vXxwpCDEa2saSrB6ru2Ef4ittBVn8fYDCwCjqbniU3B/g7BcBYnnMLUvQecZEqwIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApolcHH25pnffLSu90xCKJINg7wItlff/H1x529bcRoYDl171r7rqnXA4dBaVSQoIK+vHsoizucMNw1dvdlxgdjOjBxJQOzF5gT4rvFjXn6gJG41MJcAolxA12FAM3XAlYmy2tE5jIU9TXenLgnXzLuf+YYLWsU2XHFO7yQkOwakgLFVQ7hljB1lCA6gWdERj1pa/v0njvBCK2k4+n70cS0CVE4n1zeKUSM/WHhqrW1ty2N4DW47JpBlbmJLepMuR3wPnkE7vL4OR+2HmZ+x6DzdZbzo0FFvh7jdfjlX/BB84ixaXIsJfEzWZMRc6DF+7oQIJ7WkyAETX2CXp/GpQwwIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiy1mVSb6jt4Y4m0V2M3/e/azZBQUGjahidiAgIE/4ZrJn65azagf3byfJwSvyxbnUSNvvJRf4aEiktKVOWKm9HbFMB8bS972Uj6IhviNrbrI7fdos/wv7SB7lCEVETKHC8lot7mw3xD86RLzhBFlBpgKreQrN0bXGC7vkMLE5Noxxj1BdVOEL7RQf96NGgi08ksgvlOMAcEVsVrGYJbrqAW85QJYe/0oQTb9BB86gRqweaZprFPDKB0/UUlRMNNR3+Zwrp7ibb8c0QXaDJ4V+5k2ABw8Cp99uXHeK8K50nfakQnY5EUlQ8lpCIG2JHLHTF7s4TbnXJST7Jy5RSmNywIDAQAB" + ] + ] + }, + "bar.example": { + "selector._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvDckC9n3Ds9uFZ7Kd7CwT7qV69CCq4L9pWha1O1W38yGQ1wHiWFwTXG4Kc5Dr8COLnLOZDPTERKbfGC3/nr9yWVoL4c7Rkwfvzk9YmQl89znjMyXuB7R3KAKbU6uZKSuOX/HONL2LQWv4oB+ab2TE0WnZj41LYboOS+mkemSuDMVsyTGJZJvGQT8im1dnGiqPCYexVkVwCw4z8+iFo82fBkO/m5vNqfSKWHerbuQBkG1Ubm2cshXCf6JWnnbOHLqMkLNz0z9B9/P1OE6pyP1AsjJi7YwyEnICbQ6LQ7/I54IcLHDtSoTZtSX+Zn1M7ihCj7GVbPpQriatROdP1kmRwIDAQAB" + ] + ], + "selector2._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=J3pRkHKe/Bz/V/hx4xPZMhYa7dnxgtgJlrSKIGP4G/Y=" + ] + ] + }, + "test5.dkim2.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=IAG1F5P3LD1Q8Y67PeW7YJLuvrM19wpaof+dzdC679I=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YUNs4TOful7xAtEh/PcKbVRvxBOOC52crCwqRCeVUnsvyOPx/qtY0oA2qPZVzDFU/h3fyz54eqYMiOIbxJavt3+nDNf8VfyHxQfc6+JdHdcHAJDpM1EMgN5awMxvc76csMVN6hnYFeOuSZECQy8Kr2C8QPCTcoMeNmR0udfKBo17Gjx4Wg9QDlc0CrzdenXscs0+D/3Y47lN1KllQeBAR7wvTVFoFKvSZ2CvwW264Syx76viMd0+JaK0YdhAcphMuHeNWzCKA+pMVD45gtikpkOQo+MBQIV96lNXa3fEw20S1IZfCMZHMSBbLmsiDY4luCe6kA08khNaE/zBi1GbQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwSkJ+kfygUujU2u3tquvkjpPV/Gluz9k7rNnDfQwSddbZLOtDSQRt5pA04fjUcS+PraUAg2arKuGv05Nuw/X0ts7bh3N0b2Iwbg1IEGGa6gXMmJ6Lj5T0O716rk0GOvdWqLz/466MzCH8viwPwSLY25EBDD3r1Y9o58xy6VhiUuMsqttjzsk743A0wKQHr5FEYim0qnfY0ePfAr0s36XItgQaXH9pkr2CPmqSXlIwKN99h2TJVcf86dMDqxuqUnI2OilwKcGtMk2/oMxC3A5gGgFkivxUIdoKs0Y/JruR6mvnoFREbC5GToWNGCgciYbxMfaQIRO9tJwyPGl8gPpiQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsU7LuV9aZv8jiNflYQBEpGjGjzKF+PBBFSezLBkRsYQ8IKcmCa0v/BI2hC0h0DGqtOb4dz640F3oZFRyUcX5PsKinm6SChl1qOog4+3oNFs7bhe3NJm7MRgTCSKomEWKXJei303wy/iDKtm+KUL8mSFNlAr3FnVTxXq2LY+rUG786Ha7xvK7NeLN4+R22061QVf+rqWhMgZB0fEGzIAVx2C7P2dCMT1sZoPPXHajXmw36LbOUDp151tfH7LQ9qdPL+08FYjM4xoJdLy3kgHATb0bnebq0Mfxym2x14nI6YoOzqE+fcL4xJXqfVISC1Uyvx0ndNO6jajsBIbr2ihKewIDAQAB" + ] + ] + }, + "test1.example.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=hwjviTXyzUXSCWayBqE17s/4NSynQKxw58jayHudRAI=" + ] + ], + "rsa1024._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIubB7x1q3rNGDWgObuKSOyYVtVKmcJpIvtWdRzg71iGRGqMdEE18GAOk+6j+GAcHTppkh4qR1d9vOl4S1L8ClAvSFUz0azi31fLQcMpZbagyseSq9FnF4nHL/7MAA2brAXkVCQ1rZLKNHMwkXGggkA9kg+LloNfSML+utkhN3gQIDAQAB" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRtvI17L4pHwF58KhyjiN7d74ZHDZia1IOzuXA6hygEuxt0+0Ey9PJvrDpKp/JsJIiJ0Ji8hrQfeMbAX5wHpz8GAkRlWOdorPuZiMZegTU9oD9nWRO/GcAu7Ub4V1pF6AwwfykCmzKbomX7jWa1y0oNgMHMUeZAi1XveQ6cfebJOwtgqWMOTSenY8+p8hU97YFxwKXO0FsAQYvNMMSZAXPM00V/ZaxiZ1UZUCMM/uesVkU7pIOzItGEjoWUrPkIos1GGf+2nBncqNgmivPkJPFeaJXOIL1iHqKJrSzZuTxCWPTQ+JVPyeAgDk0xyGK3RbiyItPjVZhBs7sZekNGVCwIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqiTwabnGlrGDoPlSHpfiWjsbwucsezwm/iU9bjloGqothOM7XNIrS1ub2f5BNz9yQjOhWGJ+fo8DOnF9YdUKXkBxuUdt49eyClLDaUG4Q35hJBWFF1MsmihtJpo6PzXGZYP/c4mPc2vXTPd3hbAqkftMgUCOCUIUyUEXhMl/R6/XkXATcyDId3TsSyQUJk3U+2r/wQJGz5JkOxyDX1NEawfh3GDuppCUFZFWnsrEvolBGDqZk8RG2FNmRysglRau4z9GG8jieXG4NjIT/yOh3pjbYGq4tgrMVZ7AcrIpCRbEJTUCExgrh3iQRXReVPy2qhcgY6BQLF2ahiTBUm2wAwIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr8xHmZoXE3P12DNh5jhFAJjtI9kosn3bISKWYNyn2PAn8E+Ik70iScLj8bFUNcjlLRtBDo5KZ323gdoYS3AUBSJlbkLJKCnQnH6pY+rawmt5kCNJm15DTFlOyZhaMWUilFyVzzGDqXf3d/VzFiX+13GnDdwR1QOnbOTMKx9Y0+nEhlnqRwv3YFjAO1aQOdFzguxMi5wiZQIFtmwwY8GgFIVrEqFq4UCU/hc/E2YcYjHv5zg2KR/zJivfXdLOceHqzJTYdOca/IDqfat2IgOooVVsfHZCCScOutZe9JwWYt98EOiFfvmLs3pvJnBLyGM2BOZUpJnkXSDCnTKxboRnqQIDAQAB" + ] + ] + }, + "test2.example.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=U62P/YVixJeLoAzIy1dTXRufMnA5SjRvgQrcuFNf/mY=" + ] + ], + "rsa1024._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcSzEVS5IaMQZWJaqmA7dnD1fHuks1mqzY9RfQn9skWCYJZxHx0d45oSSrMt8lSKZiN4FLgbBl0jiLWXq+oPP3rUEhQrqElzyzo1Swn1Phsq45ij655pXFgZpfXvS95nP2GGDrQLcZhi5VNDg9ACoitB1CtxTipRXm8anlzLtg2QIDAQAB" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolQDA1kFcPW13OQNJv7zEGhf18S+PU8oGUOSScVYRJELDUxZzv0i1OsyNW5T2hZlmTDFRszoxstj1o8JBn9nYm6zfdvr4w8JS5SrAEx8MzI4N/SghA334hbXtXQZ3br179XVgTMGJL0OMWw2Qp0c9HQAtQNF3ckeMiPncWp8e58in5YCjvHhezl8/VGrBx+CsDKxT8JFs/0QluC6AFQuM9ZIsm3RPwf4BsPE0/ADpuA5GUUdYUzhNt2Uq9Wr82BJLt8cy1a9FVEGKxdMhgJ7Gx4hx8GpM/oaiQYMO9VmNZVz8n87BkNOnjlpYFCtfb9FH8/mYPwqaSa0DmcahfeHVQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3i4wXkWpIYib21p0CZx1dobNCYababIxIZAJO5SEGiK++C7jLgnpqg+lvTKS7eR5q1MO3ZCY19Bgm/PcigLvuLtMzap4yY+h9hnsQYzrdcAamzrpB3cjiNoCNhT0Zp7kRI6Rx0t2Uc91e0CvaFf8zAJIF4VUyQNXx9Gn/SEtNr0iQCNsPptGA1PUGUwDQUGze7fkXtnBOrgvNjILfnUC7MA6W+2+mCYtHzOkRB+t6SMutR2cDSXabjYBL5/1bweL6ABDouGgBnIj9LrY6RcbzrBpLuUAuXi71dLEHs0KW0UdImyUpE1i+thKqhNGaYnaL8KrTlDUC8g62T2kQNuHJQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDOUuL/rLDi7fohdOw/sk31eGe5CtX9UEw5pSQv/EyYwW9lxZqi9SwU8Of+z7uHLMFJi+YbYV25CYDUrDIE71WLKou3FL0WyH0U4DrZoR7CBnMjRz92Lqh+VV1PJz0t5mU8YD0O+JJ80jScKIIcC8r1qysQI9Y7EdIAWFZlYS97c6WhKVg94xeOAaRDnpbr80H29g9pqGs4Yk4Hc1r5OXptj12sBMO7JCz/4dQ2Di0JsPOwEjWNbV9ysz8EcSW/+RoFG5Iomf4/q/aW7T6tUGqdj8M0eQ0TO0xW0lc4jqKUHH85LbdZFhcDIBUg8ML6mRgSVy779MxMP7+uw3iVLQQIDAQAB" + ] + ] + }, + "test3.example.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=reUWo3pXLWHk5dILIK4NoCR3F2iACFdQ/FlhvVvMtxc=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAju2t0p4xwgCT4UCRgfNZ27U6nhQ5sHSV23hR2TBngda8yAChPInsdVyjJv+cSeZf7tG7yKrzKTM9KaKK8BzBguYrJ9DRJqg7MPPsXlZJ53Ydku3GKLcuiBmDrwUxyBGAMFxndVs+uNJkF2qi+RK0Dgd45wMZiJJF1K3bPjkkQub4Ex4MXvbIqqThthlYiKUGHFBPKg7DALkdoIlegrAP4xZ43Cszd5u9AvNdjvAr31ajjaGrGuQH+gW5kXdwZpDiQgvi0Obnr7AZeVSyr4I5CTNLoj4ed4I0AOJ3TsoZM1fFzHr/rqhL+oEKW3tA7UYGaRoXFDei0qLXhRGTJjzscQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAynXlN4POdoj82RpdpD7iG8RR/RF1TjQxy65Fu/12cV0YYX1mZLOHcvSUkxkxMKSxdZnv/7UKuQcbdQXC7jBah/JQNYzLVDUe3bKbrjsypczRPYKajxnEYCsjvoKDR9g2XtlppGrchst77wcj3SWplz2MGBk5E2SGVo1TuvuRt2S0iiye8+Z2KBaVUE3t55YxRHhjIfudboyq4Vqt6o1/6gl7eieZjqfqIBcU8k1xgEG5EG5GYCV12cUzvFU4Q5jPIzDWydppSN+jsIdSRbA8E0GweeRYumuNHryfDexZ04GafvjDwC+b9PCD5r8xiyt7N8gPNG052smGeK39N9Y/TQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmJR1PxhxeO2eZlHPZfikAOyn/95rjszoGWRZti+0VyitapUVvla9noM3w0rbFEbVwW4Y3ILqY7j1C1jiM02okbYNYwE0CC1WSTGUrSoyRV7nGFJ5n6vcLCLqE9EFJwFnUCCXDTz+90D4aiXgasm/MAJJMkBQzdrrpQTwnLGVfWYGenqUWJ1+yn8kXmDq/wub0oE5G3DEE7noCgxpzkEd6tqCIJ3Z1wcA9qnUsTBjmDLPEZAwc4ajwZ/cfXceDXnprUKlFvq9tfMReKfObT2g6/iBsesBLCsuYgHKRydNqT1+YU0GmkSQkXutgyH5o4WzkUsPim2saIiTkVPTCtbWBwIDAQAB" + ] + ] + }, + "test4.example.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=fJAwKEPblCYdjrEIPeyOFy6AeXZUBALBdGQRjSPe97c=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqxfEjz2MPZ7ZiE/uQkgPOC7mn0CweB5MZgHMqgGPQodj2DpbILnxBivC64VV5/JItBaNCtEL3UFY1YzlJOKzqjJacF66u9en4m6L6uC31vrHmVME6+rx7B5nMBlkwPheamx8Dyf+wNo3/9UCKxSdFdtiJLpLGC7Tg2ry7tpxST4Joaf9fIggc3Zmaraidk0S0uJKQq6ZKoZtjJkt0Bd+LGEnGC6C9/lrjHarnImc1bcELpJrzmneOmJO1/b4C8TXawu7luKn6dTWhujAOam+sO4vXxwpCDEa2saSrB6ru2Ef4ittBVn8fYDCwCjqbniU3B/g7BcBYnnMLUvQecZEqwIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApolcHH25pnffLSu90xCKJINg7wItlff/H1x529bcRoYDl171r7rqnXA4dBaVSQoIK+vHsoizucMNw1dvdlxgdjOjBxJQOzF5gT4rvFjXn6gJG41MJcAolxA12FAM3XAlYmy2tE5jIU9TXenLgnXzLuf+YYLWsU2XHFO7yQkOwakgLFVQ7hljB1lCA6gWdERj1pa/v0njvBCK2k4+n70cS0CVE4n1zeKUSM/WHhqrW1ty2N4DW47JpBlbmJLepMuR3wPnkE7vL4OR+2HmZ+x6DzdZbzo0FFvh7jdfjlX/BB84ixaXIsJfEzWZMRc6DF+7oQIJ7WkyAETX2CXp/GpQwwIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiy1mVSb6jt4Y4m0V2M3/e/azZBQUGjahidiAgIE/4ZrJn65azagf3byfJwSvyxbnUSNvvJRf4aEiktKVOWKm9HbFMB8bS972Uj6IhviNrbrI7fdos/wv7SB7lCEVETKHC8lot7mw3xD86RLzhBFlBpgKreQrN0bXGC7vkMLE5Noxxj1BdVOEL7RQf96NGgi08ksgvlOMAcEVsVrGYJbrqAW85QJYe/0oQTb9BB86gRqweaZprFPDKB0/UUlRMNNR3+Zwrp7ibb8c0QXaDJ4V+5k2ABw8Cp99uXHeK8K50nfakQnY5EUlQ8lpCIG2JHLHTF7s4TbnXJST7Jy5RSmNywIDAQAB" + ] + ] + }, + "test5.example.com": { + "ed25519._domainkey": [ + [ + "txt", + "v=DKIM1; k=ed25519; p=IAG1F5P3LD1Q8Y67PeW7YJLuvrM19wpaof+dzdC679I=" + ] + ], + "sel1._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YUNs4TOful7xAtEh/PcKbVRvxBOOC52crCwqRCeVUnsvyOPx/qtY0oA2qPZVzDFU/h3fyz54eqYMiOIbxJavt3+nDNf8VfyHxQfc6+JdHdcHAJDpM1EMgN5awMxvc76csMVN6hnYFeOuSZECQy8Kr2C8QPCTcoMeNmR0udfKBo17Gjx4Wg9QDlc0CrzdenXscs0+D/3Y47lN1KllQeBAR7wvTVFoFKvSZ2CvwW264Syx76viMd0+JaK0YdhAcphMuHeNWzCKA+pMVD45gtikpkOQo+MBQIV96lNXa3fEw20S1IZfCMZHMSBbLmsiDY4luCe6kA08khNaE/zBi1GbQIDAQAB" + ] + ], + "sel2._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwSkJ+kfygUujU2u3tquvkjpPV/Gluz9k7rNnDfQwSddbZLOtDSQRt5pA04fjUcS+PraUAg2arKuGv05Nuw/X0ts7bh3N0b2Iwbg1IEGGa6gXMmJ6Lj5T0O716rk0GOvdWqLz/466MzCH8viwPwSLY25EBDD3r1Y9o58xy6VhiUuMsqttjzsk743A0wKQHr5FEYim0qnfY0ePfAr0s36XItgQaXH9pkr2CPmqSXlIwKN99h2TJVcf86dMDqxuqUnI2OilwKcGtMk2/oMxC3A5gGgFkivxUIdoKs0Y/JruR6mvnoFREbC5GToWNGCgciYbxMfaQIRO9tJwyPGl8gPpiQIDAQAB" + ] + ], + "sel3._domainkey": [ + [ + "txt", + "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsU7LuV9aZv8jiNflYQBEpGjGjzKF+PBBFSezLBkRsYQ8IKcmCa0v/BI2hC0h0DGqtOb4dz640F3oZFRyUcX5PsKinm6SChl1qOog4+3oNFs7bhe3NJm7MRgTCSKomEWKXJei303wy/iDKtm+KUL8mSFNlAr3FnVTxXq2LY+rUG786Ha7xvK7NeLN4+R22061QVf+rqWhMgZB0fEGzIAVx2C7P2dCMT1sZoPPXHajXmw36LbOUDp151tfH7LQ9qdPL+08FYjM4xoJdLy3kgHATb0bnebq0Mfxym2x14nI6YoOzqE+fcL4xJXqfVISC1Uyvx0ndNO6jajsBIbr2ihKewIDAQAB" + ] + ] + } +} diff --git a/stevea/testdata/golden/emails/dupheaders.eml b/stevea/testdata/golden/emails/dupheaders.eml new file mode 100644 index 0000000..75d7e20 --- /dev/null +++ b/stevea/testdata/golden/emails/dupheaders.eml @@ -0,0 +1,12 @@ +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Duplicate header ordering test +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +Received: from first.example.com by second.example.com; Sat, 01 Mar 2026 12:00:00 +0000 +Received: from origin.example.com by first.example.com; Sat, 01 Mar 2026 11:59:00 +0000 +Authentication-Results: mx1.example.com; spf=pass +Authentication-Results: mx2.example.com; dkim=pass +Authentication-Results: mx3.example.com; dmarc=pass + +Test message with duplicate headers that must be sorted bottom-up. diff --git a/stevea/testdata/golden/emails/emptybody.eml b/stevea/testdata/golden/emails/emptybody.eml new file mode 100644 index 0000000..547af2f --- /dev/null +++ b/stevea/testdata/golden/emails/emptybody.eml @@ -0,0 +1,6 @@ +From: sender@test4.dkim2.com +To: recipient@example.com +Subject: Message with empty body +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + diff --git a/stevea/testdata/golden/emails/multiheader.eml b/stevea/testdata/golden/emails/multiheader.eml new file mode 100644 index 0000000..db61714 --- /dev/null +++ b/stevea/testdata/golden/emails/multiheader.eml @@ -0,0 +1,14 @@ +From: sender@test2.dkim2.com +To: recipient@example.com +Cc: other@example.com +Subject: A longer subject line that wraps + across multiple continuation lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +X-Custom-Header: this should be excluded from header hash + +Hello, this is a test with multiple headers. + +It has multiple paragraphs too. diff --git a/stevea/testdata/golden/emails/multirecipient.eml b/stevea/testdata/golden/emails/multirecipient.eml new file mode 100644 index 0000000..b91fd6c --- /dev/null +++ b/stevea/testdata/golden/emails/multirecipient.eml @@ -0,0 +1,8 @@ +From: sender@test5.dkim2.com +To: alice@example.com, bob@example.com +Cc: charlie@example.com +Subject: Multi-recipient message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +This message goes to multiple recipients. diff --git a/stevea/testdata/golden/emails/simple.eml b/stevea/testdata/golden/emails/simple.eml new file mode 100644 index 0000000..7790648 --- /dev/null +++ b/stevea/testdata/golden/emails/simple.eml @@ -0,0 +1,7 @@ +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/emails/trailingblank.eml b/stevea/testdata/golden/emails/trailingblank.eml new file mode 100644 index 0000000..4a2a2ab --- /dev/null +++ b/stevea/testdata/golden/emails/trailingblank.eml @@ -0,0 +1,10 @@ +From: sender@test3.dkim2.com +To: recipient@example.com +Subject: Message with trailing blank lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Body text here. + + + diff --git a/stevea/testdata/golden/expected/dsn-ed25519.eml b/stevea/testdata/golden/expected/dsn-ed25519.eml new file mode 100644 index 0000000..6e6a8c0 --- /dev/null +++ b/stevea/testdata/golden/expected/dsn-ed25519.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=PD4=; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:Zpg03v4sOGi5LkC4jW16aM2ZimtGgXUR5DU05KB40kITItzNnj34WZIoHuuKRu/MfTIg8uawrSfe7hhPEoyCAw== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/emptybody-ed25519.eml b/stevea/testdata/golden/expected/emptybody-ed25519.eml new file mode 100644 index 0000000..fd5d4ce --- /dev/null +++ b/stevea/testdata/golden/expected/emptybody-ed25519.eml @@ -0,0 +1,11 @@ +Message-Instance: m=1; h=sha256:WT8nqIyG8W1R78H1QT4oZdo1SKdQrY9JHQ4fMC+IXHU=:frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test4.dkim2.com; + mf=c2VuZGVyQHRlc3Q0LmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:fItcg/qn6YEJ27WwD/P9Xd9Fb5NTUvRFG2CUsejrVdTspaR+0iTZNERm9eYfANqsf/UTM4CqLB3uWrkorOrGAQ== +From: sender@test4.dkim2.com +To: recipient@example.com +Subject: Message with empty body +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + diff --git a/stevea/testdata/golden/expected/multiheader-ed25519.eml b/stevea/testdata/golden/expected/multiheader-ed25519.eml new file mode 100644 index 0000000..0eda2e6 --- /dev/null +++ b/stevea/testdata/golden/expected/multiheader-ed25519.eml @@ -0,0 +1,19 @@ +Message-Instance: m=1; h=sha256:ShmtblPBr8lV9zKrv7MAP81zN+N32REP2QOZnUk9Fc8=:CyfSEkygi5JDksVb4/R53JKT7GKBuBgsR1ZYpkHnQOs= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test2.dkim2.com; + mf=c2VuZGVyQHRlc3QyLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:B6D/7wr4M1QaRBWUfX3fhk1QNT1z5gvAnmDroRl3Y94VqaR2yK9jyVoIXKzQYjSseYO/U9M7mZqIhgulV019Bg== +From: sender@test2.dkim2.com +To: recipient@example.com +Cc: other@example.com +Subject: A longer subject line that wraps + across multiple continuation lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +X-Custom-Header: this should be excluded from header hash + +Hello, this is a test with multiple headers. + +It has multiple paragraphs too. diff --git a/stevea/testdata/golden/expected/multirecipient-ed25519.eml b/stevea/testdata/golden/expected/multirecipient-ed25519.eml new file mode 100644 index 0000000..4edb197 --- /dev/null +++ b/stevea/testdata/golden/expected/multirecipient-ed25519.eml @@ -0,0 +1,15 @@ +Message-Instance: m=1; h=sha256:H+VUb6aLBKEh3HADN5AHzR0BQT/Mst1Gs8OylrwE9jY=:hp66YMSkgILq+EkTq9fWZj609/jmBH9ey8ppXqAtZZ0= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test5.dkim2.com; + mf=c2VuZGVyQHRlc3Q1LmRraW0yLmNvbQ==; + rt=YWxpY2VAZXhhbXBsZS5jb20=, + Ym9iQGV4YW1wbGUuY29t, + Y2hhcmxpZUBleGFtcGxlLmNvbQ==; + s=ed25519:ed25519-sha256:dPvjRNbvfparZ9FqWjh/unVORwN3X7L6smtha7faDRSlI64P2s5idSzpgXhIEp/sF8OKMNmuXGvtYgIt6JMMBQ== +From: sender@test5.dkim2.com +To: alice@example.com, bob@example.com +Cc: charlie@example.com +Subject: Multi-recipient message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +This message goes to multiple recipients. diff --git a/stevea/testdata/golden/expected/simple-ed25519.eml b/stevea/testdata/golden/expected/simple-ed25519.eml new file mode 100644 index 0000000..4a731ea --- /dev/null +++ b/stevea/testdata/golden/expected/simple-ed25519.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:453KeawDJ1E5rrWV0DK02jJNxAHV19hScwC6w/n8Xcg+W8VpBSvPZ89kIMZtEnA1MisWB5F20gCWML33cbefCA== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/simple-rsa1024.eml b/stevea/testdata/golden/expected/simple-rsa1024.eml new file mode 100644 index 0000000..d88b62a --- /dev/null +++ b/stevea/testdata/golden/expected/simple-rsa1024.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=rsa1024:rsa-sha256:XVWduqc0BZqiC++cWeGGUETvucjVmLPSHePacQyLPwwfC/Z+Vh3XA661BsUHCt+IMVtBN87bpwqRy1Fr1377Ej5S4Ij65/+Bgc+i9e1Ds8wfvNd12mo6AixzdVRPGpWR/PpgpgLcC60ghvUwrgSH+1BF5y2gy2P24UpNYTt8G2E= +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/simple-rsa2048.eml b/stevea/testdata/golden/expected/simple-rsa2048.eml new file mode 100644 index 0000000..28cdb73 --- /dev/null +++ b/stevea/testdata/golden/expected/simple-rsa2048.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=sel1:rsa-sha256:Fvfo9hwROU92/BILSbo9Tp9Y2PqJadvtT3lmY6YSxyTxL6qDIgzfgXk4jYLs12VWokmU3GzpwTJXBj6Pd57WytiE8E39Twhc+WCf/kXS3QlTcnOR2ytlE1zYzdC3l5rGZIux0RBQJ84//Nv/cjXhFLjXw+S7Rg2B2Tzgx6veniU4GqZlv51g9uaMMy7apGO5nHhYZ4psmYQ9Tx7JoBUC8ncihe5J0FBXwESwnxGvGpoYcaOyFkqkgX8IQdBNRu716mj5XOf8R9EUuzSUJ4GVZItRz9MGDraHlutuL+0f4HrsJ0y7/wN7xYs8taool4Kd0FDiBDK1gKd9ZWirJXwZRw== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/simple-sel2.eml b/stevea/testdata/golden/expected/simple-sel2.eml new file mode 100644 index 0000000..15753bf --- /dev/null +++ b/stevea/testdata/golden/expected/simple-sel2.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=sel2:rsa-sha256:PP2Wznz56eQgVP1SEVh04GeY9C7ZgHFwjX9//gVO/GKCEwZeafx1T4JTKH2oBb3dn5a4I/2K8yhJctloEa+hQKdALQLSo+X/P+BZJoS5QU2Y7H6wKsz3xdGXL+xUDha8FsP2IdZUyFleBr8DJQAvqI2m7c/KAXdoX6OpB8mbr58UBnlEE88CKl7DAFycUI6cUXCkprixFBvMDvqA1RA6+T1OoqZ3f36HaeWIQqejb3zINArM9sZz8thZy2LJo0K2XTcELZTG+Z3cExzw5VNKYCezjxY/EMwyr6AfSMgHK7PoDvPtUgAt+7G4dqqJmQLW833hs6aswvFnnkMu3dN7Ow== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/simple-sel3.eml b/stevea/testdata/golden/expected/simple-sel3.eml new file mode 100644 index 0000000..1accec6 --- /dev/null +++ b/stevea/testdata/golden/expected/simple-sel3.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=sel3:rsa-sha256:H4L18Pf5/rSdplKsqqUmod7YPfxIN/OdlRZd3DqB2Qc1hpud/9cFFe9v0cWPQERoVWO+HUrKzCtI5fEpSJOqXgohfabOVgnjvG/862QLA+JmK4zjuiEqSqUhVustu9DyvSs3vAHzsvlxZaASghaOk91VWkU9SD4C6rri8t4/FXWW3+imQ9Yo8dox6aaoy/RBYfKVoqpyfJO5fUZDOVGeKWOCdOOd6oQLkdpozQx2Hr5uKBmAECTTH7ClEdSgKw9ShNUXvMtSf2dXeVv/6dNPM+cgR3c3wQtXnyWEPAoEpRUPIuRn2Yvl62ScryTNt6P0/fEpffy5GQb3cX0QjwzhXA== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/simple-sel4.eml b/stevea/testdata/golden/expected/simple-sel4.eml new file mode 100644 index 0000000..4a731ea --- /dev/null +++ b/stevea/testdata/golden/expected/simple-sel4.eml @@ -0,0 +1,12 @@ +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; + mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:453KeawDJ1E5rrWV0DK02jJNxAHV19hScwC6w/n8Xcg+W8VpBSvPZ89kIMZtEnA1MisWB5F20gCWML33cbefCA== +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/golden/expected/trailingblank-ed25519.eml b/stevea/testdata/golden/expected/trailingblank-ed25519.eml new file mode 100644 index 0000000..3ec9601 --- /dev/null +++ b/stevea/testdata/golden/expected/trailingblank-ed25519.eml @@ -0,0 +1,15 @@ +Message-Instance: m=1; h=sha256:YtDwzM7AADKC0ryh1KVt1aZ0lmSI7tSh0ZSprpfk4tQ=:769Te581VmTppQtDpBb9xdyD4tmnTJCPgtfQRQvDo2s= +Dkim2-Signature: i=1; m=1; t=1740000000; d=test3.dkim2.com; + mf=c2VuZGVyQHRlc3QzLmRraW0yLmNvbQ==; + rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; + s=ed25519:ed25519-sha256:exjGDLlg1ATdFWqwnlOrR9EkXACjKzM4ICseTg6mjmr6/dQlDHGoNX5PQ4KVS6+4ex4GypMQvroMF/cdEbjsDg== +From: sender@test3.dkim2.com +To: recipient@example.com +Subject: Message with trailing blank lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Body text here. + + + diff --git a/stevea/testdata/interop/python/emails/dupheaders.eml b/stevea/testdata/interop/python/emails/dupheaders.eml new file mode 100644 index 0000000..75d7e20 --- /dev/null +++ b/stevea/testdata/interop/python/emails/dupheaders.eml @@ -0,0 +1,12 @@ +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Duplicate header ordering test +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +Received: from first.example.com by second.example.com; Sat, 01 Mar 2026 12:00:00 +0000 +Received: from origin.example.com by first.example.com; Sat, 01 Mar 2026 11:59:00 +0000 +Authentication-Results: mx1.example.com; spf=pass +Authentication-Results: mx2.example.com; dkim=pass +Authentication-Results: mx3.example.com; dmarc=pass + +Test message with duplicate headers that must be sorted bottom-up. diff --git a/stevea/testdata/interop/python/emails/emptybody.eml b/stevea/testdata/interop/python/emails/emptybody.eml new file mode 100644 index 0000000..547af2f --- /dev/null +++ b/stevea/testdata/interop/python/emails/emptybody.eml @@ -0,0 +1,6 @@ +From: sender@test4.dkim2.com +To: recipient@example.com +Subject: Message with empty body +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + diff --git a/stevea/testdata/interop/python/emails/multiheader.eml b/stevea/testdata/interop/python/emails/multiheader.eml new file mode 100644 index 0000000..db61714 --- /dev/null +++ b/stevea/testdata/interop/python/emails/multiheader.eml @@ -0,0 +1,14 @@ +From: sender@test2.dkim2.com +To: recipient@example.com +Cc: other@example.com +Subject: A longer subject line that wraps + across multiple continuation lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +X-Custom-Header: this should be excluded from header hash + +Hello, this is a test with multiple headers. + +It has multiple paragraphs too. diff --git a/stevea/testdata/interop/python/emails/multirecipient.eml b/stevea/testdata/interop/python/emails/multirecipient.eml new file mode 100644 index 0000000..b91fd6c --- /dev/null +++ b/stevea/testdata/interop/python/emails/multirecipient.eml @@ -0,0 +1,8 @@ +From: sender@test5.dkim2.com +To: alice@example.com, bob@example.com +Cc: charlie@example.com +Subject: Multi-recipient message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +This message goes to multiple recipients. diff --git a/stevea/testdata/interop/python/emails/simple.eml b/stevea/testdata/interop/python/emails/simple.eml new file mode 100644 index 0000000..7790648 --- /dev/null +++ b/stevea/testdata/interop/python/emails/simple.eml @@ -0,0 +1,7 @@ +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/interop/python/emails/trailingblank.eml b/stevea/testdata/interop/python/emails/trailingblank.eml new file mode 100644 index 0000000..4a2a2ab --- /dev/null +++ b/stevea/testdata/interop/python/emails/trailingblank.eml @@ -0,0 +1,10 @@ +From: sender@test3.dkim2.com +To: recipient@example.com +Subject: Message with trailing blank lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Body text here. + + + diff --git a/stevea/testdata/interop/python/expected/dsn-ed25519.eml b/stevea/testdata/interop/python/expected/dsn-ed25519.eml new file mode 100644 index 0000000..9e886ab --- /dev/null +++ b/stevea/testdata/interop/python/expected/dsn-ed25519.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=PD4=; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:V0YKqYi6+5/1x21k7Dy7TQwFvy9XFyAjZBYylYeJ5EEpm2eSEHzUGuI6jW3V2XkZhit+n6fRXDxwXwFrWdpGBg==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/dupheaders-ed25519.eml b/stevea/testdata/interop/python/expected/dupheaders-ed25519.eml new file mode 100644 index 0000000..9ff83b8 --- /dev/null +++ b/stevea/testdata/interop/python/expected/dupheaders-ed25519.eml @@ -0,0 +1,14 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:zDgNzlCYUwP5NaIlMGW6M2DX8Hxkg3IiTBMtA8Y4qeNQnVwTmwu85T+vICRD9RcAj8rzSgf0NHoDO8ngl3XSBg==; +Message-Instance: m=1; h=sha256:OyaRiaBoDWxvNxZ0WlZLwfV6BpXBKgnWiNQ9/BzbXo4=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Duplicate header ordering test +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +Received: from first.example.com by second.example.com; Sat, 01 Mar 2026 12:00:00 +0000 +Received: from origin.example.com by first.example.com; Sat, 01 Mar 2026 11:59:00 +0000 +Authentication-Results: mx1.example.com; spf=pass +Authentication-Results: mx2.example.com; dkim=pass +Authentication-Results: mx3.example.com; dmarc=pass + +Test message with duplicate headers that must be sorted bottom-up. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/emptybody-ed25519.eml b/stevea/testdata/interop/python/expected/emptybody-ed25519.eml new file mode 100644 index 0000000..498486f --- /dev/null +++ b/stevea/testdata/interop/python/expected/emptybody-ed25519.eml @@ -0,0 +1,8 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test4.dkim2.com; mf=c2VuZGVyQHRlc3Q0LmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:zXYUW5QyxUFatHvQLYgNUzWXk1glURYuByLc42h9I+BifmSI8hE1e3+HcXKewbNFF3LDnfW5WKQGeatyIXV+Dg==; +Message-Instance: m=1; h=sha256:WT8nqIyG8W1R78H1QT4oZdo1SKdQrY9JHQ4fMC+IXHU=:frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=; +From: sender@test4.dkim2.com +To: recipient@example.com +Subject: Message with empty body +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + diff --git a/stevea/testdata/interop/python/expected/multiheader-ed25519.eml b/stevea/testdata/interop/python/expected/multiheader-ed25519.eml new file mode 100644 index 0000000..7cc0d64 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multiheader-ed25519.eml @@ -0,0 +1,16 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test2.dkim2.com; mf=c2VuZGVyQHRlc3QyLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:itzQ2yFsPDzWMXpUw8lpVGTqq6A9ZDkhxjxqM0S0F55h93sCxwI9PB1XBaXms+aO/CGFKYZfugNxjdQvu/IQAQ==; +Message-Instance: m=1; h=sha256:ShmtblPBr8lV9zKrv7MAP81zN+N32REP2QOZnUk9Fc8=:CyfSEkygi5JDksVb4/R53JKT7GKBuBgsR1ZYpkHnQOs=; +From: sender@test2.dkim2.com +To: recipient@example.com +Cc: other@example.com +Subject: A longer subject line that wraps + across multiple continuation lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +X-Custom-Header: this should be excluded from header hash + +Hello, this is a test with multiple headers. + +It has multiple paragraphs too. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/multihop-3hop-dup-headers.eml b/stevea/testdata/interop/python/expected/multihop-3hop-dup-headers.eml new file mode 100644 index 0000000..e273080 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multihop-3hop-dup-headers.eml @@ -0,0 +1,21 @@ +DKIM2-Signature: i=3; m=3; t=1740002000; d=test3.dkim2.com; mf=bXhAdGVzdDMuZGtpbTIuY29t; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:tPjgehmaehtXTm/P+DBJ6nUxoOSaPRa1TfnLrjcUQ+7YVgt08sIv5sDg6hYPTxgTd+t5U1YNbAkAUMlrrcMvBQ==; +DKIM2-Signature: i=2; m=2; t=1740001000; d=test2.dkim2.com; mf=cmVsYXlAdGVzdDIuZGtpbTIuY29t; rt=bXhAdGVzdDMuZGtpbTIuY29t; s=ed25519:ed25519-sha256:R+2DFBIxMMPPlFEP5cKXiDFw84STGXkP3w9PuL5Hinr8vdvbbAPQC7XQF2iCftKdtSXHwXRUueHHDd5Pa3eADw==; +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVsYXlAdGVzdDIuZGtpbTIuY29t; s=ed25519:ed25519-sha256:i3qrQXXSVl6ZSIQny8wigs9bK0Y/iN4ARbBQNF7hhnCGno1geZvsWR6M/MCf+226lK8/sj7ypRJK9F7P5t2aBw==; +Message-Instance: m=3; h=sha256:grO+5I+f1P4INGFBMNJQORQWtCy9bWc1mEgklAC8bnk=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; r=eyJoIjp7ImF1dGhlbnRpY2F0aW9uLXJlc3VsdHMiOlt7ImMiOlsxLDVdfV19fQ==; +Message-Instance: m=2; h=sha256:mC3/mIcsks+HXZ+BEWnCpjvMxvyA/q9WBV21hGK8Tbs=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; r=eyJoIjp7ImF1dGhlbnRpY2F0aW9uLXJlc3VsdHMiOlt7ImMiOlsxLDNdfV19fQ==; +Message-Instance: m=1; h=sha256:OyaRiaBoDWxvNxZ0WlZLwfV6BpXBKgnWiNQ9/BzbXo4=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; +Authentication-Results: mx.test3.dkim2.com; dmarc=pass +Authentication-Results: relay.test2.dkim2.com; spf=pass +Authentication-Results: relay.test2.dkim2.com; dkim=pass +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Duplicate header ordering test +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +Received: from first.example.com by second.example.com; Sat, 01 Mar 2026 12:00:00 +0000 +Received: from origin.example.com by first.example.com; Sat, 01 Mar 2026 11:59:00 +0000 +Authentication-Results: mx1.example.com; spf=pass +Authentication-Results: mx2.example.com; dkim=pass +Authentication-Results: mx3.example.com; dmarc=pass + +Test message with duplicate headers that must be sorted bottom-up. diff --git a/stevea/testdata/interop/python/expected/multihop-body-footer.eml b/stevea/testdata/interop/python/expected/multihop-body-footer.eml new file mode 100644 index 0000000..e00b979 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multihop-body-footer.eml @@ -0,0 +1,14 @@ +DKIM2-Signature: i=2; m=2; t=1740001000; d=test2.dkim2.com; mf=cmVsYXlAdGVzdDIuZGtpbTIuY29t; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:eempZ0/mrsh8vRDhNPnpCR4KZpE8aBFvUZbd+Ii8pFCxT8l7Xg5jpXsx90SHf3P/8oMwzzTooTapbzH8+bFGCw==; +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=bGlzdEB0ZXN0Mi5ka2ltMi5jb20=; s=ed25519:ed25519-sha256:oKZmf7rabJv40npkboBxaJCfwrDe/GIB6Ktj0bWlJmE1WGRuYqUJGT8ttznhBd3MHhfyxRtxyNVkBUAlbrHGBw==; +Message-Instance: m=2; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:DGv24YWxV2Z3AJ/C+rbwX078dNL59U5evazyN5MyTSE=; r=eyJiIjpbeyJjIjpbMSwxXX1dfQ==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. + +-- +Sent via relay.example.com diff --git a/stevea/testdata/interop/python/expected/multihop-dup-headers.eml b/stevea/testdata/interop/python/expected/multihop-dup-headers.eml new file mode 100644 index 0000000..5964ba4 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multihop-dup-headers.eml @@ -0,0 +1,18 @@ +DKIM2-Signature: i=2; m=2; t=1740001000; d=test2.dkim2.com; mf=cmVsYXlAdGVzdDIuZGtpbTIuY29t; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:+opAMQw0qqBZORfAKxNk0Inhw8rMyC0dNK/GGvvHjkMJZvTfMDla+7egKkX+JU2SPNHqdsqBQWW4PofAMlUIAg==; +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVsYXlAdGVzdDIuZGtpbTIuY29t; s=ed25519:ed25519-sha256:i3qrQXXSVl6ZSIQny8wigs9bK0Y/iN4ARbBQNF7hhnCGno1geZvsWR6M/MCf+226lK8/sj7ypRJK9F7P5t2aBw==; +Message-Instance: m=2; h=sha256:mC3/mIcsks+HXZ+BEWnCpjvMxvyA/q9WBV21hGK8Tbs=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; r=eyJoIjp7ImF1dGhlbnRpY2F0aW9uLXJlc3VsdHMiOlt7ImMiOlsxLDNdfV19fQ==; +Message-Instance: m=1; h=sha256:OyaRiaBoDWxvNxZ0WlZLwfV6BpXBKgnWiNQ9/BzbXo4=:1qpsCHgYA5m9tWU1x8yom2ztdiaAQirhqJujNRLDbAs=; +Authentication-Results: relay.test2.dkim2.com; spf=pass +Authentication-Results: relay.test2.dkim2.com; dkim=pass +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Duplicate header ordering test +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: +Received: from first.example.com by second.example.com; Sat, 01 Mar 2026 12:00:00 +0000 +Received: from origin.example.com by first.example.com; Sat, 01 Mar 2026 11:59:00 +0000 +Authentication-Results: mx1.example.com; spf=pass +Authentication-Results: mx2.example.com; dkim=pass +Authentication-Results: mx3.example.com; dmarc=pass + +Test message with duplicate headers that must be sorted bottom-up. diff --git a/stevea/testdata/interop/python/expected/multihop-header-add.eml b/stevea/testdata/interop/python/expected/multihop-header-add.eml new file mode 100644 index 0000000..75fbf9d --- /dev/null +++ b/stevea/testdata/interop/python/expected/multihop-header-add.eml @@ -0,0 +1,13 @@ +DKIM2-Signature: i=2; m=2; t=1740001000; d=test2.dkim2.com; mf=cmVsYXlAdGVzdDIuZGtpbTIuY29t; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:FDYZopU8W+c7ScdhFkFgomHP7XJvK73TMd5oMBBiZqHCZYroaQz/Uzq9E4Cgzmk44jH/NySJSRrjZ77v/ymLAA==; +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=bGlzdEB0ZXN0Mi5ka2ltMi5jb20=; s=ed25519:ed25519-sha256:oKZmf7rabJv40npkboBxaJCfwrDe/GIB6Ktj0bWlJmE1WGRuYqUJGT8ttznhBd3MHhfyxRtxyNVkBUAlbrHGBw==; +Message-Instance: m=2; h=sha256:u4RFAizDeEqt9fTv108caRt1NNgrjlvxOf41EVAS/Bc=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; r=eyJoIjp7Imxpc3QtdW5zdWJzY3JpYmUiOltdfX0=; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +Received: from test1.dkim2.com by relay.example.com; Sat, 01 Mar 2026 12:01:00 +0000 +List-Unsubscribe: +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/interop/python/expected/multihop-header-replace.eml b/stevea/testdata/interop/python/expected/multihop-header-replace.eml new file mode 100644 index 0000000..dc2c5e3 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multihop-header-replace.eml @@ -0,0 +1,11 @@ +DKIM2-Signature: i=2; m=2; t=1740001000; d=test3.dkim2.com; mf=cmVsYXlAdGVzdDMuZGtpbTIuY29t; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:GvKXKsR5BrEghRdjM0P97a72lx7k8bnaWgbw2akordHhaSlGc/J2oEXS4gdMXC3fE+1s4D1HrHxXywsVzHVcCQ==; +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=bGlzdEB0ZXN0My5ka2ltMi5jb20=; s=ed25519:ed25519-sha256:BFU0Oz5jnaLq2Twt9Mp+xMuQLdOxR3NaDSox/sJQtYczUMsH0iVcunFqxgMbJpSx9yrNz52fpetIek+ngV7TBg==; +Message-Instance: m=2; h=sha256:YQR+cQdwRx1EUHN5Hs0I0rdcwdmvtXuasYBEq2djXR0=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; r=eyJoIjp7InN1YmplY3QiOlt7ImQiOlsiIFNpbXBsZSB0ZXN0IG1lc3NhZ2UiXX1dfX0=; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: [MODIFIED] Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. diff --git a/stevea/testdata/interop/python/expected/multirecipient-ed25519.eml b/stevea/testdata/interop/python/expected/multirecipient-ed25519.eml new file mode 100644 index 0000000..8c8f351 --- /dev/null +++ b/stevea/testdata/interop/python/expected/multirecipient-ed25519.eml @@ -0,0 +1,10 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test5.dkim2.com; mf=c2VuZGVyQHRlc3Q1LmRraW0yLmNvbQ==; rt=YWxpY2VAZXhhbXBsZS5jb20=,Ym9iQGV4YW1wbGUuY29t,Y2hhcmxpZUBleGFtcGxlLmNvbQ==; s=ed25519:ed25519-sha256:pHP9FyVXQ2R3hikKFkvI6lAOqvFUq2SZHnJ0//reSHxernd4BVYL09qiMKL+wTqCAbyB/yHbqll+qmvYLcraDQ==; +Message-Instance: m=1; h=sha256:H+VUb6aLBKEh3HADN5AHzR0BQT/Mst1Gs8OylrwE9jY=:hp66YMSkgILq+EkTq9fWZj609/jmBH9ey8ppXqAtZZ0=; +From: sender@test5.dkim2.com +To: alice@example.com, bob@example.com +Cc: charlie@example.com +Subject: Multi-recipient message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +This message goes to multiple recipients. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/simple-ed25519.eml b/stevea/testdata/interop/python/expected/simple-ed25519.eml new file mode 100644 index 0000000..c846f53 --- /dev/null +++ b/stevea/testdata/interop/python/expected/simple-ed25519.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:F//Dt+leS4H/m5LHwv0hWWCjq1UeVBgE0wrKI0GLcuN/iKhdiytBgPMqS+tIlbSNJmYnB9LldrQ9jPnTHRK2CA==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/simple-rsa1024.eml b/stevea/testdata/interop/python/expected/simple-rsa1024.eml new file mode 100644 index 0000000..cec4c92 --- /dev/null +++ b/stevea/testdata/interop/python/expected/simple-rsa1024.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=rsa1024:rsa-sha256:s7Tg+3gg8dmoONLoFtvlMGmcClNkcHXiXdcwfy2WMQd6iQrsfRNC3Z3ztcTTFDZXh0Pc6KCXLfvwU2FVuify3UyHc43QYkrSKKYzZHzqC/mZY3NWApVBY+GRg/BnEgNt7+SiSBbLdDvEmi7zwvRqmM7pQydhR1RBt1APmu1imgs=; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/simple-rsa2048.eml b/stevea/testdata/interop/python/expected/simple-rsa2048.eml new file mode 100644 index 0000000..51f1e64 --- /dev/null +++ b/stevea/testdata/interop/python/expected/simple-rsa2048.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=sel1:rsa-sha256:QX6uddm2dv2enRr/W9rPSww897sC3V/5Ai71VGkA3hhHwwlzknauDnAbY0pDJMIpcR243wpXGECHeY51LJoJpNSoW0zqlP0iTR3P4L4R2CyiuQ8JiqqVLCLHW4bmeJJUcp3fb4jXydYP2YNdIY7+TeVtMr7qPrJguMngsc++1jaxQW4dn+LKubEJ3Imrdq8mitkObeu9TJOn5w++0lLQ3LN++hkcxT+syh26MoZJhLlGm8O6D2ITIbgKVwpmmas4SuoRy+8G3KBbSqmhXCOYv5Z4X7CmMqvn0JVTSCwjSekPrJe7AfnaMmi2ffF6WMWpoQqTCl+ZpLvXc5oW420HzA==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/simple-sel2.eml b/stevea/testdata/interop/python/expected/simple-sel2.eml new file mode 100644 index 0000000..59320b1 --- /dev/null +++ b/stevea/testdata/interop/python/expected/simple-sel2.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=sel2:rsa-sha256:YYpWh+E+9aMAtCQruAHcJU/BOdbnu0IiWYMIHaIz6+bwh/7jsqOAp7bVrEtvLeNEGYubvtAGcnPJM0CtfWLhKrBa73dNha8fE7va/uWrciLTO+MB8Kx47JPbMK/7DszvHz2MyDIPWZAg9TjUcobj14+QiLvutV3OwvM6Dk/cqCyBPnIvCGBawXY3GpvURjqBTzhq6YBzZNIpFygoJtYznkeFrxuriKGOZ100ApheJRJL45smCe4s93nvuh0OpfQGC6299tei/Au7fWqXb0PfDW5E2YAPRjp8q9nd/Xj9pixVdBWmfdEPxj5bO6+o/iG1Zu8wfA85dbwj7O6JrO0tGA==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/simple-sel3.eml b/stevea/testdata/interop/python/expected/simple-sel3.eml new file mode 100644 index 0000000..69d4241 --- /dev/null +++ b/stevea/testdata/interop/python/expected/simple-sel3.eml @@ -0,0 +1,9 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test1.dkim2.com; mf=c2VuZGVyQHRlc3QxLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=sel3:rsa-sha256:ecZyPHB8rD4agWFZuH01Pp9VijZiCWMkKMSgehLju8/zPy4jiU5rSuW57ntJej0xMNiiGUbIenXv4i0b43iT7hpdZKxOLhUQ4pOww3G6MY7tKUIF2d1P6PySN1CKkhbFe5N8rwyqvbOWl6vQOPDTvpwPgFmYJuU1zw9MQnZkdonyNPq5qQgLo5C/sfE+swGsw0UvVOLfXycEX3+CLu815okx79PAub3EaC96O10lMn85jFwGiai4okceuUs/uTmRWo1E94jVW0piqBgHGLFeG4cInmZrqFQhZo/O9cmyPj4NlxRYkAr1M8HBVVFh+/ZtKaMNHT4byvdZkf8+SPsTOw==; +Message-Instance: m=1; h=sha256:SLtzk6LO68CCaX4edrJ6yfpWbp3hwgvI8IdMBRLDk+Y=:SgG5fNGEg1x24MwItCUYGDHQkWKng06W1/IvTGBdwzU=; +From: sender@test1.dkim2.com +To: recipient@example.com +Subject: Simple test message +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Hello, this is a simple test message. \ No newline at end of file diff --git a/stevea/testdata/interop/python/expected/trailingblank-ed25519.eml b/stevea/testdata/interop/python/expected/trailingblank-ed25519.eml new file mode 100644 index 0000000..5bc7fb7 --- /dev/null +++ b/stevea/testdata/interop/python/expected/trailingblank-ed25519.eml @@ -0,0 +1,12 @@ +DKIM2-Signature: i=1; m=1; t=1740000000; d=test3.dkim2.com; mf=c2VuZGVyQHRlc3QzLmRraW0yLmNvbQ==; rt=cmVjaXBpZW50QGV4YW1wbGUuY29t; s=ed25519:ed25519-sha256:uYFhE54W/xw9zIf1j4aiVVsdA1w82HhZLd+878Hli/FyrWoOXHf0A0Y1ip6JuRWkxxl/OQRojSGHM5qe244wDw==; +Message-Instance: m=1; h=sha256:YtDwzM7AADKC0ryh1KVt1aZ0lmSI7tSh0ZSprpfk4tQ=:769Te581VmTppQtDpBb9xdyD4tmnTJCPgtfQRQvDo2s=; +From: sender@test3.dkim2.com +To: recipient@example.com +Subject: Message with trailing blank lines +Date: Sat, 01 Mar 2026 12:00:00 +0000 +Message-ID: + +Body text here. + + + \ No newline at end of file diff --git a/stevea/testdata/keys/README.md b/stevea/testdata/keys/README.md new file mode 100644 index 0000000..9cecd3e --- /dev/null +++ b/stevea/testdata/keys/README.md @@ -0,0 +1,6 @@ +# TEST VECTOR KEYS + +Keys in this directory are only to be used for signing test emails. +The key "badkey.pm" can be used instead of the actual key for +creating signatures which look legit but should fail because they +don't match the DNS entry. diff --git a/stevea/testdata/keys/badkey.pem b/stevea/testdata/keys/badkey.pem new file mode 100644 index 0000000..e32d5a3 --- /dev/null +++ b/stevea/testdata/keys/badkey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCdMVOvwg9ElF69 +9nE2Tn5hclubw2KFJ9vNSHlM117831m67VDmN+1uli8ovFElHl+bSZK6mx3Jo//m +Lsx7u4dsefSbSIBZpHAE3aTlNYk17RxpwTQ2cy2r9rkcPAoV0AShFpauj4ndDMVg +VBBmoiLd80cSbAmpLgByBsDjfcysbWdHTe+NaRJouJZ4u3KPeUeBbnb70/kDSX1b +49uXnZfIIb2cEFtKsBbnpUCfW7ZWGRHIjvldMRD/rvC7/nNmX7Sy4Ue9a31k0b4g +Sqq3BsL2YoxJxe7F/GY1ZxHvIIA4Dd2kYYUieUI0d5XfIYWnc89sj3hD/iluEvCz +eMQfGDLZAgMBAAECggEAAe+Ej0p9c6O8EuzA8iE/TyXgWOesA+W8wCdfTwHr6oM5 +kKKxd1RC+TSiOgcvnJxmIvxh8ShN0pjRYt06qV9iyIQr7emTowbyVi0Ahc4F9YMI +8Yhugykh69cq6l0W5rPcCrvDeTzNPsqUd54xYYInmcIQIvrvl4S4fq70mxiGd60Q +PQDKBr4/tBzebx+TfClfAPSBwd0kbcS1kgmsGqBiDbpxSxycHf72rdDFfgbfhUbo +XmWWx4yzbHnhMdC9ApYGTFnxQox4OkUaXkqK7my02SUCbFvkKJ88DDmw44HyakfW +8bdD/8mgtGnBnmyHfAfaGcYTSVZO9NerSeZh1Gmt7QKBgQDcslhHxrUXZ9Sh/H/N +92zouRGj8Xgl9WFzQoWAPU7/1qi1cwCq0lK0KaAtM8sV0BptRu49KKwpQhwgIaZu +WIu98A2b6t6j44qeAn5Bit544TduGwFA6+DC3wFdLOsJTs08Q1ydI5q1Ey3CZLE8 +kcz6MYJjLSTwS9cO2NCkh+7czwKBgQC2VnUrkpdJ4KtQzM4B8OqxKFHQEV6WLPNa +Hq80PUj8JpvrbzLUH9VmTUMneCNUBlWsqFe3uFUDSGvmlo70dJj/NPOEVLKrlw+5 +2FrGIStgBLfJDLm6sgNRB879BdRrGgYwlpBCEgVp57l6mSmS0fnnbmK2vid8BSsH +GyheJhFv1wKBgQCvic877+oUHsr6uNpy2a0vghxs2OBm2MDVK4ECGkGern9sK3fJ +ZxbPJBi6O2r6A5cxeex+ir485s92Iy28sQMdZTV94ZeKpa6YtYT03t7LKN+s3R/n +InaYqUXtdY/QzHPjzfR5LFueUQ3hg7ORaKqwCfcaqUqrMrdwoLV7WH998wKBgGou +JsVc9CTdZGYFHeIbfU/0/s9/jek9++h6/eQZ3CqaASo08xB8CTUtwPF3EHuZxMJq +tcoS2c6zmcIJ4u2QCJh1KWniOLmbmljLGPFP14ZuuOenNXD2wN+rAGpvvqcv3HLm ++tK+09A8VNKD5qXNCQ4wRePNEAk0qT3WHsfUu0q5AoGAOyJaGEA/qH2pOfL5R2Zw +IGWGXKIh4XySkbNkmlV8LcE0C2jnhP6wfV+uuK2jwiutYf6w8v36+/mu/YVyYTVr +w4IlVJiIe4cuTjlRBYIg/oTqYUKVqraEkv/786FsN8nVhGCioo/xMj4ZsOrOyygr +mpCHNl6doBJEdDzLobOIvZk= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/ed25519._domainkey.test1.dkim2.com.pem b/stevea/testdata/keys/ed25519._domainkey.test1.dkim2.com.pem new file mode 100644 index 0000000..c57b41f --- /dev/null +++ b/stevea/testdata/keys/ed25519._domainkey.test1.dkim2.com.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIIOQVf8MDGvvmIkpUbgoqtyUIxjlzRqaBR6aP12tcGGE +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/ed25519._domainkey.test2.dkim2.com.pem b/stevea/testdata/keys/ed25519._domainkey.test2.dkim2.com.pem new file mode 100644 index 0000000..67a6e62 --- /dev/null +++ b/stevea/testdata/keys/ed25519._domainkey.test2.dkim2.com.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIDzErxntsDYOeUZEcIznbTH/iCLmdYkwt9dG6xtfdfM1 +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/ed25519._domainkey.test3.dkim2.com.pem b/stevea/testdata/keys/ed25519._domainkey.test3.dkim2.com.pem new file mode 100644 index 0000000..bc24171 --- /dev/null +++ b/stevea/testdata/keys/ed25519._domainkey.test3.dkim2.com.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICzvxJIXzw80GCPujnuGVHREi9kIa0zxHkweookXPPvw +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/ed25519._domainkey.test4.dkim2.com.pem b/stevea/testdata/keys/ed25519._domainkey.test4.dkim2.com.pem new file mode 100644 index 0000000..885c038 --- /dev/null +++ b/stevea/testdata/keys/ed25519._domainkey.test4.dkim2.com.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIKqcBfStdhMEilMLVTaCmBHYRj8vNJTHGx90RX2kvlpO +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/ed25519._domainkey.test5.dkim2.com.pem b/stevea/testdata/keys/ed25519._domainkey.test5.dkim2.com.pem new file mode 100644 index 0000000..39984bf --- /dev/null +++ b/stevea/testdata/keys/ed25519._domainkey.test5.dkim2.com.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIM+cCIFf7+vG24o4hUbUGh8B//9CvDGU+MKcdAh9YkRz +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/rsa1024._domainkey.test1.dkim2.com.pem b/stevea/testdata/keys/rsa1024._domainkey.test1.dkim2.com.pem new file mode 100644 index 0000000..120fe3f --- /dev/null +++ b/stevea/testdata/keys/rsa1024._domainkey.test1.dkim2.com.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMi5sHvHWres0YNa +A5u4pI7JhW1UqZwmki+1Z1HODvWIZEaox0QTXwYA6T7qP4YBwdOmmSHipHV3286X +hLUvwKUC9IVTPRrOLfV8tBwylltqDKx5Kr0WcXiccv/swADZusBeRUJDWtkso0cz +CRcaCCQD2SD4uWg19Iwv662SE3eBAgMBAAECgYEAuVjd4i4/UoO+IzAnTM+/BCqJ +jl8zDwF+05cKhLtljMwY7DZFalMI155XwsTDS5aryCh6FpQVkHLRAX9gB37EvvXC +TJmgqVmKI/9S8DrcTs3JbXM2YURADQnADgl2WOjsJOY+ibt74ZX04i7sIw7r8ruY +lT/WUhQU6ZUUFLwqGtECQQD6LCqdW9NpOVY6uTIGpG/OTXflIVNPqW1il3iQTj4E +g/yql6SFPKzOUZShjbFnZ8VDdNA6XNlRTu19f63msouTAkEAzWaomUt69fAl/p8e +kZ8z51DU/O6BrMjzv66nn5Glu0lJEHE5hu5iK36Wt4ch1L5hmKFLrrHD1EnZePwG +GTOlGwJAYMh5bAs8TLb73xgKfHtdLcjWm3Q7ENSRFtVWtS0v3Pta4nxsD4ebqu3w +vFdezIxeymwEqq+E+2m3gxfEJT3ptQJANLdIRi+tGznLRpIoEk+9znxcbZ35AhOB +G40D0FxdILgqXGHy1cKQx4DxxaCU+jjya23aMrfE/tMFUZu9JPKwSQJATj9r02ft +TI3jLdO+rQhZboCu7QJG8u8ebRLgsHolcRZQmMQuxdgPW/dQMAMN5pzCHR6rI+K9 +TgoPZPJLIwfNYA== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/rsa1024._domainkey.test2.dkim2.com.pem b/stevea/testdata/keys/rsa1024._domainkey.test2.dkim2.com.pem new file mode 100644 index 0000000..9ab4d35 --- /dev/null +++ b/stevea/testdata/keys/rsa1024._domainkey.test2.dkim2.com.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBANxLMRVLkhoxBlYl +qqYDt2cPV8e6SzWarNj1F9Cf2yRYJglnEfHR3jmhJKsy3yVIpmI3gUuBsGXSOItZ +er6g8/etQSFCuoSXPLOjVLCfU+GyrjmKPrnmlcWBml9e9L3mc/YYYOtAtxmGLlU0 +OD0AKiK0HUK3FOKlFebxqeXMu2DZAgMBAAECgYA/tF7VUa1GdbbnYq0epSR8YSt5 +Tp5BOiGR/A6O/itZL0SQo4XE8HPbJoZ8G5H8Li4vaO2y8CYay9geNBoS1mi2Rb8y +4zyM+VKqS9uwvYauf2qMMh1oJxaLN9Nwz92YJBryzrF0rKRe5nuMY4y/ZGmLDmb1 +v/c9Noe+MyvABoc9IQJBAPDJ91fecW8BCk6OP2+3yrEJWoDV8TNDvWhKEUTt+rCI +Hl6twWvz7MDmg2H1ulRabpz+hHgqeEcygAsx6ZpyLTsCQQDqNccB4nKa5Gf5KlZ6 +ZZJwgINx4lLaBj+qPlusQagQYcDm66J0W0dsa4ysbC7Miqii7PpzeYc/41aLISr0 +6pj7AkEA5LdC/bDqWIFJdALEGO8gVfdHgzc0q/g9MpGgQ7Q1OnehOVeccVk6WqTa +yTLq8XMQvIk59RaKNz6tx3R6q1ymvwJBAOTQUzOBI0w2F2ozNbdwYkftFf3+Ccmd +TzeoonFA18vhZM91qAobX0UKMa4GJxaH5Mb/1JNaSItfNB7K8afDRl8CQAX8KoBN +n2n8g5laJKsz5TPgXRJ9H4dlR06fIhYGjkPHL/6NWpRhGQpnLMfFwab/u3/pI4xT +1J3pomAs+hkpSl4= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel1._domainkey.test1.dkim2.com.pem b/stevea/testdata/keys/sel1._domainkey.test1.dkim2.com.pem new file mode 100644 index 0000000..21e8cec --- /dev/null +++ b/stevea/testdata/keys/sel1._domainkey.test1.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpG28jXsvikfAX +nwqHKOI3t3vhkcNmJrUg7O5cDqHKAS7G3T7QTL08m+sOkqn8mwkiInQmLyGtB94x +sBfnAenPwYCRGVY52is+5mIxl6BNT2gP2dZE78ZwC7tRvhXWkXoDDB/KQKbMpuiZ +fuNZrXLSg2AwcxR5kCLVe95Dpx95sk7C2CpYw5NJ6djz6nyFT3tgXHApc7QWwBBi +80wxJkBc8zTRX9lrGJnVRlQIwz+56xWRTukg7Mi0YSOhZSs+QiizUYZ/7acGdyo2 +CaK8+Qk8V5olc4gvWIeoomtLNm5PEJY9ND4lU/J4CAOTTHIYrdFuLIi0+NVmEGzu +xl6Q0ZULAgMBAAECggEACHW8KzsZVYQy9xHGugApp7Cw7JYD7/l8wXZsfLW3y1Ou +0gCaucymyHkp8CwWRpzOt/9HXXlweefuIx1w9ZEujFDO03qUETln7+OuwuRCfwSC +jvDj26laC6pgzj7MlIzpW20ddczb3SGvXjw9cOWQ3D/5u8LDaRIfgHEoVh6nZyzs +3aN0f+ZrpCaX4Az9xon7khG5c4QRlGkam4SDJkUPOPpuIQC3VvNxyFXH2cLSb3fD +1ftxUQUIXOQ2D7KwHaZwgTOyOzukAKxAAfK/A1DdErovxsH2wrXVtASslgHeLSAG +BKlX5rm39QIpdj0ttiLHSGdCftdNZCj6B6lvDLUY0QKBgQDY4ooDYO5ctDiP60J3 +7wj7pkX7NrOiEe1ck/3HQUh4xV+g91IhacAjtEgHw48dak5tmUzx+jpD5RorHk5o +dcXb/LkXdyyzQbaXRjoeyat+2zVULEhEPnznXJrK+IFE+sVfKQcRMp2xYpqUtnkq +Rzgev8/vsNXExw4shynCHhVQuwKBgQDHmwWJ0Eygo+2xTQXnOLaSkQzOywJoDbtS ++jqOfjTLNhaJ2F2AFwaZtUHVOP3274IGG4WXqhK5i//l9RYXC80ApE8Awf7baJiP +bwH/lXVJ3RyllaTDfuGfn4JEcPfNj90DXHXwSUCAUQ0PiWfUrehDbj6Yni2JKSZ1 +QnlCHAbv8QKBgFmEl4ZxebDVygrNBbBO/xwvMu9PUrFwSNJPWw3lCi6e+KuNqV6V +MXMLNDfQJb/0Ys6l2bZ+m5Hue/CUH7TJ95KnKxZeYBrU4rJpkA+pGZnQjKBjbqiM +Si2McW+ZRnHcN57hZkNY6vGOS1NaRYSHxMgcT40VJgaESntihn/SvuWFAoGBALPN +bgi9jTkrUaLj6gxl5vhSFwJ0lp9at0JAy9ytzSq8d0MGlvsaQsTVJQ9IPmVhTHPp ++MYs2p8vgH0J8DMkWy9X0x6wob3NE5go+9jaLgQyXGrASOIdemqKihLS3DXcgM9V +S5v+sE6mNCipstE/MrkDJvnditFCMzVvgsFNc+ZRAoGBAMqg7jPDXlVLCwZo2Hjq +DK+HcOvhZlKdfisUiup2rRZPgi41MCJmYh5S9Z0X/xHkUGGop7tuleeCz8VZbzpw +auAjij+U/hZ50GPHD+1vIOmd/hArJRHDayou7gLEdQuoTYj7gYa1D6/EnWT5Uu9C +vdOYCKklkHQUFWEZq8ZAcDZf +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel1._domainkey.test2.dkim2.com.pem b/stevea/testdata/keys/sel1._domainkey.test2.dkim2.com.pem new file mode 100644 index 0000000..12438f6 --- /dev/null +++ b/stevea/testdata/keys/sel1._domainkey.test2.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCiVAMDWQVw9bXc +5A0m/vMQaF/XxL49TygZQ5JJxVhEkQsNTFnO/SLU6zI1blPaFmWZMMVGzOjGy2PW +jwkGf2dibrN92+vjDwlLlKsATHwzMjg39KCEDffiFte1dBnduvXv1dWBMwYkvQ4x +bDZCnRz0dAC1A0XdyR4yI+dxanx7nyKflgKO8eF7OXz9UasHH4KwMrFPwkWz/RCW +4LoAVC4z1kiybdE/B/gGw8TT8AOm4DkZRR1hTOE23ZSr1avzYEku3xzLVr0VUQYr +F0yGAnsbHiHHwakz+hqJBgw71WY1lXPyfzsGQ06eOWlgUK19v0Ufz+Zg/CppJrQO +ZxqF94dVAgMBAAECggEACaWnhi/oswq56QDunSuCri4dyrjJEONe6ogfA15Z6BKc +pF4uFNnraNmkzWhwovc4yNliqASugtNR7CLqXOX+csBJz4gzOnZ0IsyY5bbQnMmh +GLDjnJAonq76g9tSBuDrvnn9Vc/T+iP4L3doZB4VO0aXnNUEdnFTkj7wVsexfTaN +dQ+OwGOh6uZ5pg+jZic30tZLVPnmNAfYx6lRaaEASJ9aymWNmlaGcnYxf31wYB1F +o+AQg+BWRygu/cmuQnQjHpid37SDgXb3XamU4AGa1LUunO1XaW7VaLi8Qi0bcEwU +nFie48zZhVXHv66zmb0zQF1/iTpVY2yo1BzkDZGzUQKBgQDY+UsTM3jAwQhemuEC +p6solPd4hNviSf6Bl7xMl3mje+XTu1PuGPubl8xdQWeS4sLk0OJmWeFUh4arcNyP +IzXOcKV3/G4DdjeOiXuKmOBAW2ZDsWX+mH8CMGMUD2MR8QkHZcKNRZj/l5g9Hbuv +HpZ1/HVWgIATNMhnSVImcp9YaQKBgQC/hoXtZmF03jqXXZyyulDfDH1SfxQOvSuE +ogr/J3iJO0QRmccJfHjqCNk874gLRHX9nE6TlWFwbuVHghzJ8W5fLTxAwobXkrCN +8s7ZA1RH+8grPrUIzLHvBYOQS9GVY4FVPjWoXjuOwlF7prBtbyfHr0LzQP48UCKl +XUhivg56DQKBgAZiDxtZgEvrYdumosf5NsfS2hTpk65sjtgxJpD6Q9HTa0d3U1jF +02EYbiKwMePYV/NzUbXBHyfldMjGYTa0ynhwR+3ntzTS3X+L+95v8Ojzx2ZmaaG7 +ysC9u6xzZ263sJRPIhIV4hJi20+w+DyGodaGvsz0qs/l1Z90QL5w0M9BAoGAIckw +TP23iW8+dL89ykWVyJMidHAYcLUsPRx8xiAHYmYDDvvKocDEb7yZ3eb/X8V27wMo +1V4Pr07QwXmRbIpbhwyqyV3HfbXR0WiY1q2Aq6Yd3IsWUnQRm3GkkMrfMPQ5t3je +7FGbPgbHawGvs85c/RvMuq6naqpUQkmfYFPYp+0CgYEAqYAE/nG1ZmspI3fsYDh6 +MdPOjSeAhBGexspWgq+x0cG5jLtNWwjNtlB0JxSr4hpPfh382LGy5OTJN0GzN64U +tR2n42oWI3ryPkCYjz4vk3fDIvqlF6IFvr1dU8rdBpS8iH/T1uaniH04gsU5RNGy +FrBeGpjP7SUInXwqkFOh3uM= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel1._domainkey.test3.dkim2.com.pem b/stevea/testdata/keys/sel1._domainkey.test3.dkim2.com.pem new file mode 100644 index 0000000..1de0630 --- /dev/null +++ b/stevea/testdata/keys/sel1._domainkey.test3.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCO7a3SnjHCAJPh +QJGB81nbtTqeFDmwdJXbeFHZMGeB1rzIAKE8iex1XKMm/5xJ5l/u0bvIqvMpMz0p +oorwHMGC5isn0NEmqDsw8+xeVknndh2S7cYoty6IGYOvBTHIEYAwXGd1Wz640mQX +aqL5ErQOB3jnAxmIkkXUrds+OSRC5vgTHgxe9siqpOG2GViIpQYcUE8qDsMAuR2g +iV6CsA/jFnjcKzN3m70C812O8CvfVqONoasa5Af6BbmRd3BmkOJCC+LQ5uevsBl5 +VLKvgjkJM0uiPh53gjQA4ndOyhkzV8XMev+uqEv6gQpbe0DtRgZpGhcUN6LSoteF +EZMmPOxxAgMBAAECggEAAx14SUPAJSCu2G0YNsc7ZUPcCxO5Bajm1r2WuBd7XAu2 +edv0p62AYTbzid8ulHhATUPWNkuOiO54VerYYR8ZUx1p4aBmHJP0VU7trwktq20q +PBhxVhQXACmhMnx4HyqNexBag2bplSzluFJvSqMvGDKuvoc1ZGH+wmHHKmIjsY8P +j/5Wxje/DJqkLbBPC0C2/IjA+LyB7CTQyNPyyBr+kDBzW/aMLMHXNRgXsna6vdKj +U6SW7mUqkqhxTNWmoHRtSHTyPft1n41bC10nvhn+51Aq+YIrB10w/R3/UW5ORRR3 +3EylpLyLy90iuQ8/ssjr60Exb20q7GXZjddNeVs8eQKBgQDEgianOTVcx8G2wrCo +0W9xtfbxwR8wM5BpBQRgTGUBEP7ntTi5Ao6jWGMJhF/Aih/mFkCdC+sjKo2aJswG +hT74WsXSkBGpUS5PzjDWB0cW8nJmqB+q/1yWht7t3GKMQkgXt4KC26JTH18bKWvV +5UQGv+7yjdQOsXoq9B4qthO1qQKBgQC6MvVUsYZRrdj38nKoq4klJXL4dqC7H0p1 +F2lfborr1sWV30vjAKoVweYaL7i6/SWB+wjc9CNl7bAux8Neke+tqPO4CvCZ7bBY +sftnwbSUT8doLsNUv7rpyx2O+I3p2GXJJEVF72s6ZrGLgvw3nDSA5R8ZwAN/DxNP +ZwP5ffItiQKBgArZ8Q5870BvW3gfKig4YIIT/U3+Tx2XS1sxRruWnGFBsgHTlQ1K +YdwNTnLBF5SHTBmlku3ZMaPgfq9Lgyq9Auwt/wyQnhkOfZM0nKfFARz4SG74y77i +mRdK3VafO+Xo5xhALvnQ7RbP/a1/TRAjt+zzfltwj4w8KrXigyPyqAX5AoGBAJvc +iiYiECiRGLy8kyQDquKQTYQUcQkR8VbAiTEcLGesOWryJujXJojd7UpgRgcoXlxG +ka27I2JwK3mNOEoSaQn6EkEtzydaorIsEiFRMWKwwV/BPTrU35ZSsuR5xPlJbjKI +DQC0oBKb4eTBeXTnab+3i2gXphWWvfhKaDZRKgbZAoGBALhKafEN908wSWfgHdpt +joWxGlfghIuWqmJ7h7SQM+hI/uH5xObPJ7RVCHvcTxsfGc3AFSnhgpO+21m87mIh +Fan4m0t0LdavulHN+O+zIeuwhW+TszWtM7m31P6I4E5UOUE34Bq7TgzAB/nNT1/q +Qilb7rjFFEvRsVSwBuGFTpI+ +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel1._domainkey.test4.dkim2.com.pem b/stevea/testdata/keys/sel1._domainkey.test4.dkim2.com.pem new file mode 100644 index 0000000..e499f87 --- /dev/null +++ b/stevea/testdata/keys/sel1._domainkey.test4.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrF8SPPYw9ntmI +T+5CSA84LuafQLB4HkxmAcyqAY9Ch2PYOlsgufEGK8LrhVXn8ki0Fo0K0QvdQVjV +jOUk4rOqMlpwXrq716fibovq4LfW+seZUwTr6vHsHmcwGWTA+F5qbHwPJ/7A2jf/ +1QIrFJ0V22IkuksYLtODavLu2nFJPgmhp/18iCBzdmZqtqJ2TRLS4kpCrpkqhm2M +mS3QF34sYScYLoL3+WuMdquciZzVtwQukmvOad46Yk7X9vgLxNdrC7uW4qfp1NaG +6MA5qb6w7i9fHCkIMRraxpKsHqu7YR/iK20FWfx9gMLAKOpueJTcH+DsFwFiecwt +S9B5xkSrAgMBAAECggEAG6ozMUotdAVMfVsXVoZaV6ZVyihmoHWdutzNoCuyY3qK +7Xq6I3eNfoby9KxRsLifgucmlToqJfOcDqPCpY02qe2MbCEVJqx/TAC+BROjBlL0 +lVk0MYWC6sAbxU5F2WIPujuib7zy13Ixnd9ug1LI97DKlM++tsFTmCth7rdJnCL0 +8dk15yaTMJVBOYv6MRUoed+sfxqg7D3tIwy+4JRHhqbxSsk9EXpjkoeS971F+Xfu +azY1gXfukUJagqOJhWFAgn+a2aMrBPe/USCv3P81kUrHzVf0oxLS3WZduLkBUlrh +O3N8LOt6M+c96nh5CrU/b4iY2gWahozIM0ZgvntpKQKBgQDkSNYpShp1psS2b8TQ +30Zs1sRA9AVLhg8JuyFqcpUj6o1WSLdxZg3Wci+5/0opYod2LkuBxc6mGAzAOjZi +Ni+0PoUUuLq8Cp2Ppncjde5NLHvncnXYqxhcXjBrDJdC0A22iRboDTnYugXRWEds +ksrLvFVmqLbrParKqWkbWTupFwKBgQC/3WTPkvm5zDlvRC+lENIwNwKoGgrsmpC4 +5wsmynT9o0rwZCS34QajTzQKWCBqRrasAGgjUcs4THFw1HHBLOTu5wuhye95EiVq +2cayP5yOX7GrL4xkoZ8Dt6u/t+kmsa402Fmk0tWV+O5AoEeke2cxcVjIIgbPbiTq +wdYYwdbVjQKBgQCNx8rzA+ohDf14jTxAuZskt9JVwPiU6mLmmUlsslN8Zg13/Fuq +W0bW3D5WF3746w/Uz3Xn2HG66I7qyHzETIEeQgInV3/qj/FBqZKu/GyPk8Q0f/s/ +ISxVGc6afcxoG42Yw9XtM2GtAAPi/CAIB7daB9NGmhxZSAiOK99ys94A6wKBgQC7 +bHZSH207hriEbMNuJ+RrnRFHTUlmoAbH2UsXdaabLqzB79G+nl9xoKlhgX0I1FMV +6r/P4NL4CBS49463+jcY+TJvtWftiUBsBJkI/wLcQba5VxV6KDfB1eY8vldPqTpX +/RB05lAm1KlJEElr7/B6aBMmTbcBYsNFYFzYimJm8QKBgBTINEfHeDiTuNu67O76 +czh0CClZizIdsMaY6PT7Z03RyblIpnmvC98gsTL0owNM1cbLV1E5sp7FOeKuvhKU +ePGipyjZ07sEDRL1XnUR8P+FGSQHXVGNp2oyVLeBNgMVfutKL4lJYHJie/1G1Ppo +XTpWtukjiYoKb5/bvL4WqFp/ +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel1._domainkey.test5.dkim2.com.pem b/stevea/testdata/keys/sel1._domainkey.test5.dkim2.com.pem new file mode 100644 index 0000000..b0234d1 --- /dev/null +++ b/stevea/testdata/keys/sel1._domainkey.test5.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDRhQ2zhM5+6XvE +C0SH89wptVG/EE44LnZysLCpEJ5VSey/I4/H+q1jSgDao9lXMMVT+Hd/LPnh6pgy +I4hvElq+3f6cM1/xV/IfFB9zr4l0d1wcAkOkzUQyA3lrAzG9zvpywxU3qGdgV465 +JkQJDLwqvYLxA8JNygx42ZHS518oGjXsaPHhaD1AOVzQKvN16dexyzT4P/djjuU3 +UqWVB4EBHvC9NUWgUq9JnYK/BbbrhLLHvq+Ix3T4lorRh2EBymEy4d41bMIoD6kx +UPjmC2KSmQ5Cj4wFAhX3qU1drd8TDbRLUhl8IxkcxIFsuayINjiW4J7qQDTySE1o +T/MGLUZtAgMBAAECggEAA9XEja87YWF63A3caVmlGMKtIPXBMhrlnPf6JSoSSoYx +y2uGTPcxbR0UaBbHhRFLN1wtAcVWuz7M84lTd2cAWriGWcvAmz1hx5WvIAdhQ8y7 +K/cS+mVYdMrmp3cK63S/qvyXpZRon1U9nfV4RCsjwZYvm05B5E8eYdLnrf2BWs2c +MzSgl7N+KRtkCCv+twdVX/LcY3KVyTVZpWynorm6jkQV145NnOwVRRQWIhE6P9R/ +vnFzQkW02oP76K3676gHI+xk5AOGzSaxlKgklwyE36/XECcv6YPd2ukTIDb+jJ+y +6rXcsii5Dgiw8VGYupBW5jAxx0ruBmTGYH7/4QmTGQKBgQDvhxjBGSOUqeGBXXmn +l0l2hV88+30+gFwSBCVvZQq+rIYjukomWjKcKRqMeHz8rr810hVsPCTlbljQz1Je +tdCiWAr05MuOy+ju3xt24SzS0MgHBOdolyyTxcqSMcnt/ImBg7m69A3CJFy6C4yt +LAgmGoCAPWo1QaemVtc1OHeWlQKBgQDf7aoBzFKPPdtpOeKHWhcSj8QWXh2aCnRI +f2NDFqyVFtazlfXzvnsWawoKEmK+mZYT/kfRKvWn8hAb7X6gq1c+W79+eIlo2guB +JF4b/LN4QZD8/63+QMNqF35etEkTeYUh2dXZwId3kR32bw6Bc3ZfMyytjd19Op7R +BB/dt8YyeQKBgFMDNiAOD1fBfIbyF2xusSYjWPtwiVp07/PKfhLaKNwmPKL7OCGM +lwep8yqFw4NrKJeFhKkrOvpMwPk1MO3kAzeQXdO7y5RktQi8R+9uYLN7aieMmoqU +Af4AyZDbMomJQTqipWbWmEIVihFsWUfW/8f83VYUcs+cPncWl6U5ObD1AoGBAMHd +/jjOT4/qCuKAFen/CkispSSEWPZ7JI97klGL+OCexET4iNZ8zA3tn+R2ZH0FdVRb +6othuQqs3FuBcwbhMIgxYIm7C6P2Ws100fFDB5IOmdsf9OBR650XN/X/+eBdbfCP +qsB6Gg5fWeR348QZVZs4L+8WCnMX5FWrT3diWrppAoGAP2LHFo8Y8umRp5IMS4OU +1a+UZlSj0Ra5ZIKm+YZ97j3gzV+H9j4LDQCYOgLUayHX4jvPoz5LEmMr4uQiazVb +jm5UvPCtIxLOw1yQWIJ5dsc9aswK9++J/RI0MEJ6x1KJaWxa4DszVl5zXYUbhHTf +NCBBcI6zvINRieMAopeTPTk= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel2._domainkey.test1.dkim2.com.pem b/stevea/testdata/keys/sel2._domainkey.test1.dkim2.com.pem new file mode 100644 index 0000000..b97f67e --- /dev/null +++ b/stevea/testdata/keys/sel2._domainkey.test1.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCqJPBpucaWsYOg ++VIel+JaOxvC5yx7PCb+JT1uOWgaqi2E4ztc0itLW5vZ/kE3P3JCM6FYYn5+jwM6 +cX1h1QpeQHG5R23j17IKUsNpQbhDfmEkFYUXUyyaKG0mmjo/NcZlg/9ziY9za9dM +93eFsCqR+0yBQI4JQhTJQReEyX9Hr9eRcBNzIMh3dOxLJBQmTdT7av/BAkbPkmQ7 +HINfU0RrB+HcYO6mkJQVkVaeysS+iUEYOpmTxEbYU2ZHKyCVFq7jP0YbyOJ5cbg2 +MhP/I6HemNtgari2CsxVnsBysikJFsQlNQITGCuHeJBFdF5U/LaqFyBjoFAsXZqG +JMFSbbADAgMBAAECggEADKLL/1tug2AxQYy9S7awTE7fJm/rDpyZMWm28LnZteqV +O9fGX66KIZbERyx0pR4/UD8BCLfejhaf65YPH5YQpuRMAzG/WFr7XCc+X6b5ANDg +ylXAzx/RtwHb7Vsp3+o7TuxpKyhNsN7oRqKK9rDuexikhhAzHD6TSEkJw/D/qg/8 +M4GdFkAtY35fe0lHDzqr2XQMpLvstZvMwxSxhGSLrlQSfpYS1hI2E7QhythnRyDK +5dTxzy5ALHlYUPsu4Wb9x4fMYsIEsm8Ey9A0v5jE332a6JY6HZFPPmpFut0YCLyR +Swmux1yedZySVuxx9SuCFFsIw21enC7ExVxucL0XRQKBgQDgBph5rE7FwdkANG/T +hEZgBE4XLkGqZRGBibWWCph92CATFLUH8oBtKxscyh0XYWXPEJb9ldaQYpTVhAIz +Ovi+fdSSino7WnUseaPoCUTMdif92UkIkutxReI/wc2oMWR4XpSzdt7cAbWZ4PGi +TJT5MwnZf6Y4kLUTpOMAaNEsfwKBgQDCbaJVDD5jincqsE0OTAL25oPYW72iKjhj +C6cU96EGgPca97cv+P1Jv6C5ic35aL3BgbMTBZiJ/GAD6DS5mbs9RlhgK+wR+9uQ +YflPKszLWSUc6kL9Mb+ct6lHjep1/7e6wC8uCpiS5XX2aRAa150+qPgyWDOGy8qP +TmK72HIKfQKBgCFnWlzqIGTEXsL1DqePVZ8DhVFXiFsL7uxNyFGzCsHY9A9kSwoA +Ur30a3qDeIr/7tpNMvZk3TVyPZcIs/tHk8dzo7qmA0vOVSJokNQMbcG+7H+hYo5w +ucKOFQpEr5Ggq8Z2890+oERVzOpA7Eay/JOb1KAltHdCvH1NXLu25VmpAoGAXfJB +tRykbM60RCFKKnVaHVlHLd0uZVRHSLyRsFTzLsRUImi90GVcZi09CUnoB/Kvdfgo +wjE7Arm630htaZ051yLNgzLV796luj9NCw7gRAXAJBeh5NwL/TrVheix+PBmI8vH +QFUIYjqT5Evh50Z2CZf1zcXGqASCzPoQRToR6LUCgYAI3TLuE5l4DG8ppj4846Ds +1B4n6h5upeRxBf6NTPlYWK9pdDKUquLPCoiCQvRUsxPJ2SwFwdNM0GoJ+Ngetjn7 +OAVkOwUzg/x5Lau/goTcZia2fEAea6gaJgK8P4bgZJw8r3yAc9ZSKdJ7D84YHpst +2nz9yj+aCXAsXEQ/vOfwug== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel2._domainkey.test2.dkim2.com.pem b/stevea/testdata/keys/sel2._domainkey.test2.dkim2.com.pem new file mode 100644 index 0000000..257368e --- /dev/null +++ b/stevea/testdata/keys/sel2._domainkey.test2.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDeLjBeRakhiJvb +WnQJnHV2hs0JhptpsjEhkAk7lIQaIr74LuMuCemqD6W9MpLt5HmrUw7dkJjX0GCb +89yKAu+4u0zNqnjJj6H2GexBjOt1wBqbOukHdyOI2gI2FPRmnuREjpHHS3ZRz3V7 +QK9oV/zMAkgXhVTJA1fH0af9IS02vSJAI2w+m0YDU9QZTANBQbN7t+Re2cE6uC82 +Mgt+dQLswDpb7b6YJi0fM6REH63pIy61HZwNJdpuNgEvn/VvB4voAEOi4aAGciP0 +utjpFxvOsGku5QC5eLvV0sQezQpbRR0ibJSkTWL62EqqE0ZpidovwqtOUNQLyDrZ +PaRA24clAgMBAAECggEAEEpgMwVWC5al4N0+vVaquSxUm9JyEjulxRF3z9XJotXx +3SYo85738kdv7lGnOsiiECkIob/AqTBG6SBmBvuEoq2fCcRqU1MT+8S2US8ZM5rI +iKG25Nx8L0RW3arHKcAUOh+e+jci/4gf08/wHI05BKdVLF5TE2MKnGTXtaRpWYY9 +GgloGbjg9gGYGeCy2IVg/ynzuu+rf1vS9jmfH+82rd66lJ5EB4iJ7ccKilUU//Zp +CVGK3zqdngn+VKMRXqWTJ/eXYlTorBzeLVjOOV88q7lh/hsdUlMe/YBzUiXN4zt/ +lEgP9LOhYuGXN2gusiWn4WDjO7vrxo2HTKdyenUS0QKBgQD51Oc0AMggYT70iBRq +gnEkOeQr1FrebkVAUcVWBwZSf6iG/ViMXLcWeWsoxSem147MlJxs3KnXkKUbpLTR +ag8zaenD8xkMNBeHZ8Q1nWbcTntzc0MYqM/tBxt6ekqYMGdE8R653rOsQJWeCz40 +jb+CNUZMa7VVQHXmEXeZk21QUQKBgQDjqoMigwDp8c4DAo6mR2L8M84bIeigjLAu +syrbBcCDe/Kp70Jph3DExgrz54PAGpnpIIpWZhEEM9NkEGDCJEWr7q9Tgy06JTHM ++sOmXOKTz4fUvjJ/tz2CJsT9Ime6BS44UAJ4lc7gqvHxralWucTUBxIzZ//8IA1+ +jTYPDRVIlQKBgC18UcvqCIW8rtWeoPjzXt4VnDFOrGyq7vjS6nbLOJ90lp5dKe4E +Q2FYIeZ+XsXFoT4mIITBeDrDHwx1ZGZsRPA7bFA8xmauZUpF3jdUvRGHSHqwlZq7 +wX+KN7qI4WPsDCFFNS7qGRRXfeYUbfLri96NDIuFYLJw1gZZT8kqSlDBAoGBAOJQ +kmMwTyxAD0a2uA9bT27eILigEJvovwrtWGC5axJJmISNLzuwQ82YATNMpY/F4dH5 +YwtYEvpWeoEyNr1HSWsMroUaFU+Dwem+Ldem5iWBW8mD+Lc09JP47kGkffBRTgz0 +nEA6y5hS1ogk1f08Vglfrhwj+jgrtL5kqaR0oP+xAoGBANFeFf5ErEGUx7a8L+2m +/94VcRlec0PACh/ftwKt6i/WBd44jwIbRPuUszj+r7kh6HwBWjUsdX3+J0dT59vm +/zSKk60/fbs8EAN4A5/rrGEoK3FOvW1kKDFCH12ixErnLjj8M+LKhRFQ1Ifg89fj +af33hXGEjogvN2P9/aFvvBXH +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel2._domainkey.test3.dkim2.com.pem b/stevea/testdata/keys/sel2._domainkey.test3.dkim2.com.pem new file mode 100644 index 0000000..e2dbeca --- /dev/null +++ b/stevea/testdata/keys/sel2._domainkey.test3.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDKdeU3g852iPzZ +Gl2kPuIbxFH9EXVONDHLrkW7/XZxXRhhfWZks4dy9JSTGTEwpLF1me//tQq5Bxt1 +BcLuMFqH8lA1jMtUNR7dspuuOzKlzNE9gpqPGcRgKyO+goNH2DZe2WmkatyGy3vv +ByPdJamXPYwYGTkTZIZWjVO6+5G3ZLSKLJ7z5nYoFpVQTe3nljFEeGMh+51ujKrh +Wq3qjX/qCXt6J5mOp+ogFxTyTXGAQbkQbkZgJXXZxTO8VThDmM8jMNbJ2mlI36Ow +h1JFsDwTQbB55Fi6a40evJ8N7FnTgZp++MPAL5v08IPmvzGLK3s3yA80bTnayYZ4 +rf031j9NAgMBAAECggEABrbpPrGW39SEOjkHIQLa4dvdcjypfMn7WstxUZ5C1til +DdqoUi+oDtZwtP4xZPEZV5Ke5IafD+rd7MAfqmGdJwQQx+3e8nB+kRCy3BDniZsk +11rlAFj069GScmeGrhPMHdItEEBhBvFpr1A3zsZIfRUT99qiS3PahrVSMRHVz9Su +h5mHcbYFkLdeJ6T091hE19X/nOLl/yI2STatBZdcwSiv9Fw8snzvBBtqKjf3iadH +ZSN44AnX5rO/gr5QweIUvUTTL0WNE6K+ZdfEH4WGN/RZmSbxEqqMb23QltwDvdW4 +uMb9O+mc+yIaK8/W5yZ8Gap12IVATRo9MEntXD/UEQKBgQD26NdHQsmMuedl1nnJ +HbTEy78WMGwbtyeXXekwK0dD/TXQkEQS0eoHqX9Ujmh7bhYM6qeHHXO4IO3yIdve +oIi1Cp8k6/LvD7EZxFArvuSOBscq1zVEFyjH6Iy2Wp5W1DktsN2riYmo4xGDfN0I +k6/g8XZwmwOnuos0+BwRUl0eeQKBgQDR6h2vaVQehbCVppKkyFJa2cuQF9apOoVb +qfZwlT1cE8LAo4zU6xLdN5Zdb/OYTVGmzFYWLsNqYrlup/sHSKAEgCax6hEhoQcy +C/9TXiSx8CTUQUXu70Zo33hIuz4oeD/wIfEpqCZJMtciHActYoBX4vZWOWiIIbYC +hp9Fr0VidQKBgQDF8Y0E+CoUw8UbA4LHFSWiz6T16QQChCcdVpBnqYqMmybVeZLZ +c6gnVF9cLn7c1TigSk7cJ2RqLRwVjcdzB0gRu94x/2bW+MGGYrfTGkqpAg4R7k4p +tFdhTLHiT2u5OJJOawiLSMwvtTqJK0UYZy4NLAdD50Ja6VZItM/roxg8IQKBgQCQ +QM/HlJ1vIDZakQ7jWJpnvpYKXqdwwjuU43bspVJ2bUn/siT8yNK83Q9jBKUk+7Vp +57AdLGO0P0XpCl0TA1Cb5ykwSIcfUhasyNvcgay77yiQCI8zhJEhTrjNWod7ri9a +KSkelGRDF0IKObKg+Lj0LI9Wb7srGeeHUtYvQCRxZQKBgAFtuSyY1MynWeka7Muh ++ChpE1Z08SjNUV6Kf/+qv6d8LHkwMBiyfVEkp3dJAqaNseLmYwFYi2A9FqOu8TI9 +RBYLDnzH0AGWjOLVAjhcDimdcTa/7s/1/qT7AnE9nObm9pR1lxnvjzHxntRBbFPH +HVwg0xl3ZZNn+dynvxjPNuze +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel2._domainkey.test4.dkim2.com.pem b/stevea/testdata/keys/sel2._domainkey.test4.dkim2.com.pem new file mode 100644 index 0000000..fc3fc62 --- /dev/null +++ b/stevea/testdata/keys/sel2._domainkey.test4.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmiVwcfbmmd98t +K73TEIokg2DvAi2V9/8fXHnb1txGhgOXXvWvuuqdcDh0FpVJCggr68eyiLO5ww3D +V292XGB2M6MHElA7MXmBPiu8WNefqAkbjUwlwCiXEDXYUAzdcCVibLa0TmMhT1Nd +6cuCdfMu5/5hgtaxTZccU7vJCQ7BqSAsVVDuGWMHWUIDqBZ0RGPWlr+/SeO8EIra +Tj6fvRxLQJUTifXN4pRIz9YeGqtbW3LY3gNbjsmkGVuYkt6ky5HfA+eQTu8vg5H7 +YeZn7HoPN1lvOjQUW+HuN1+OVf8EHziLFpciwl8TNZkxFzoMX7uhAgntaTIARNfY +Jen8alDDAgMBAAECgf9D0aZzbHkrKG3pb+ygB3d9+j15e+UsGObbEKqDsDnsleSO +HnX608WXJcnF/jpXflF9UKq+rrfvEZdi/lu1XCnsP5C9i1aTwYw7J1z0t94gWIiy +S6B2OrwjtAxW3r4vY5mVGl44fBenqBMk5+ex76YhupkDmGIicR5Kn9qbgm/+fq7E +f/GZCM7DaMPKLIjZyGmpIV8h7grgiYyTB3rRWOE7lAlOoFegUic3/EEmkIQTlUY8 +mPwYDBrgnFs103A76xFNOcy/88BFSdiqyHlmewVW6ONxMlfeqbLPNjkvs8YUHFTq +ZH3YZzO2nC2C+cHgI0qCrTOxhRQS1yr7p03bAPECgYEA1eXpkAdpG6OQpDGtx3Ik +hBdVkHPcOBO9Tjwzugsey8C/UWYy+Kjy/Zz9VEFY9u2TJ1hML4NIc1vRxsK/YLpD +lJKN6dyEKyYSipEnwiODG8nmwD2i5GliYPn5b7NRJ3IhpB3wWsTQWs+SOkTmywqT +/Y5gvxdTy8gkzEKhXPoGfJcCgYEAx1DzzQYoFfwd/5UiU1KTir7LxkX5fYg/yZvz +GJKWehNJevgIXkuOW+iPjZSUELsZ7099jfSP6Sb3cQixC5IhRanmGvJiEDSAGJJl +2ouCIbC9cEAPHdtr+LzxnvHk9hVZ09b23McdrWJbU3JcVyCXtcNlxEElgu84xR3N +g0OC1rUCgYEAsgx8vCh/BMPFjffQeRn5bX7i+aiH4qIDzZDtWesRynd6ie5xdW1l +P1kjwrPWs00VVgX4/P/iGiyPVU0c8w55XL4VpVqrJiO+AVxM5RyccVBCZTmpU0qp +2qGaUbHJVvV5LVzCizwTV9Q7G4fpOZBnmgOfYtGvLg0/HExGd9ej+KcCgYEAkRr+ +Y2T4CgliTY/lTYPwpjIBaHfJCvsGdWBzuo/9vSINfNjWPtAC9CZ7XDevE/8jAnZ8 +kbxaiQM1YbVSL3pTfsQSIcwUWHfgSgNK47BtLNnfELmSR8pW8N+diHnSltU0cGUv +k8vibzgMBNRdJZE04b8/d4F2Lby7N8ZauBWOS4ECgYBqg7pMg6lwFUTpn6uWmjow +VG1+6blXFkelpO25FGMcGzDn533Rxhlb8rOW79MHkkFrC8E/Tzua99kA0U5aWlXu +SBDPQQ3jRtzzc1mYEf/dl+rbtnzODYu4CZRGzCyCs8+QHHuwt6z5vfiMALM3tp/X +SoHCTvxmr1oh451yb9wWCg== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel2._domainkey.test5.dkim2.com.pem b/stevea/testdata/keys/sel2._domainkey.test5.dkim2.com.pem new file mode 100644 index 0000000..47db145 --- /dev/null +++ b/stevea/testdata/keys/sel2._domainkey.test5.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDBKQn6R/KBS6NT +a7e2q6+SOk9X8aW7P2Tus2cN9DBJ11tks60NJBG3mkDTh+NRxL4+tpQCDZqsq4a/ +Tk27D9fS2ztuHc3RvYjBuDUgQYZrqBcyYnouPlPQ7vXquTQY691aovP/jrozMIfy ++LA/BItjbkQEMPevVj2jnzHLpWGJS4yyq22POyTvjcDTApAevkURiKbSqd9jR498 +CvSzfpci2BBpcf2mSvYI+apJeUjAo332HZMlVx/zp0wOrG6pScjY6KXApwa0yTb+ +gzELcDmAaAWSK/FQh2gqzRj8mu5Hqa+egVERsLkZOhY0YKByJhvEx9pAhE720nDI +8aXyA+mJAgMBAAECggEAJE6Q9Aza/ceYWwFHxluuBCfWqxqeQrxvPdjV2Y8ZFoAW +yy5krDAn7mFdpwUHSuLWuZX66wnglW0mRkYzISKguB8CJDgQ7EgacQMtZTM7z3eI +aK2O9qs7eO25ppWKP9bxw+wWPeZ3hFCudRKemjHB+34J5dnIbJoiuuMc6oDa/QG1 +OUoyWzVyLZI4PnveEnY/aCak5EoaBi7uuZu9w/WYFNL1mbnuT6mmWvGc8L9CJWKn +Duonsoz3Q1QtpHciJxgjnDcTR3CKvU/qFiC/FIjJukuYlXQQ2Y/CU60RhKuI+0w4 +3CActxA3g5b0Y0do/zuJZ4DOuMhI8PX/Z4lVbUL14wKBgQDmmUGmlFvDo+uRgqN/ +LO/ZfX+VOaAK6QopvHoXFCiPZ9cVYs9sauwpUh+2hnT21SpUoWChJuhZvtWQqW/5 +esRoIfPpsET7pYcayPs0nkVBl1LiaVAmjvoduFk6fB0tsgsjfTdtBMSwnzB4XRfP +HlxpZABbvsgDaiHZTj5ThALz/wKBgQDWcAsnqJy0RCd7mXqvGOPhTxnup5idaQs4 +yUA5iTIuSu/45v6BR6yF5gQm5e+85rqVZQwcbQpJt67kWnHX9ooPUMn/QEc7JCau +LeMRcPvpvvjVycIWzeI1FbSxm77RGVnBqsAHKwFH5XA0xiIgcMHGrlUWAP4QkNua +M3Snx0OCdwKBgE0LFywYlTAgLCxJgWaUFO6NR3sL7bS3d3XKSuA7E/IVt36lrLeE +YbLMhVvuQFXXknUEFczBuw+A+smCUq7/nW9NKLkC84lAHIuOap+B4ZGwhTiwBt9b +FaWoWed4YCdMPPx2cXYzCaQZC5CrSbEha66qgpQkgZ7ibryzr48no/TxAoGAe3We +toKZViA3Ky4+ODl4WewU3haYA2jLETKtS3L19Tkn2IIF3aDKb3zyvwJ7eKLOody/ +kE4nMjIS+14nVVOYQSEea1syp5Y0vuukZBJMt1NAKLY0jLSUnEOW+PrWbcTOrHYG +gRn9bstmQrwgQdUpe547VqPTou4DwVAGxeIvuBkCgYAWpFSAFhng5WKzaB/9bj3t +yVZMvBCDv0rnv7SfyTEJmgemC8p9rVR0KfhAOdYVyaaiVWynGBVTUP3I5jCWTdBv +031lIVgthZEsBqvx8yluu+GbQZZCT9cX9wEJ78jGdnJZ8Sg0WHMwhuXh60To43Jr +JBDWMS8xpRS+mb4s1gxn6g== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel3._domainkey.test1.dkim2.com.pem b/stevea/testdata/keys/sel3._domainkey.test1.dkim2.com.pem new file mode 100644 index 0000000..7b363a6 --- /dev/null +++ b/stevea/testdata/keys/sel3._domainkey.test1.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvzEeZmhcTc/XY +M2HmOEUAmO0j2SiyfdshIpZg3KfY8CfwT4iTvSJJwuPxsVQ1yOUtG0EOjkpnfbeB +2hhLcBQFImVuQskoKdCcfqlj6trCa3mQI0mbXkNMWU7JmFoxZSKUXJXPMYOpd/d3 +9XMWJf7XcacN3BHVA6ds5MwrH1jT6cSGWepHC/dgWMA7VpA50XOC7EyLnCJlAgW2 +bDBjwaAUhWsSoWrhQJT+Fz8TZhxiMe/nODYpH/MmK99d0s5x4erMlNh05xr8gOp9 +q3YiA6ihVWx8dkIJJw661l70nBZi33wQ6IV++Yuzem8mcEvIYzYE5lSkmeRdIMKd +MrFuhGepAgMBAAECggEAFZWIwviy1Z4atLFWV0xYWILuNsUOZJ/kPDTBbuIYr5+I +3838EFub7p+BmiayFqp0TO34Nl/NDdjXI5fR/M0lgB3hg0Cq7wX3SYeZp51pv44v +jj1u56cSrziV3lWY2VC4Bqw5bod/SVrj5PQbbcA7gls/bgmznMpGj62lzbObFeV9 +Grl+6Uxced/bz7tQlQsJVnqFi56bd7LRnCBLDCKMCRZhK/Q3mOtpRQMzNzuNuCmr +19Qgd2q/6skyKX5edY5zXWTL/XAvktP99JbDeF4vqwNP08pvvuas9O4IPZArfpSB +4hL6P+j1Z/5FVZStXXzIss22f3eUxtylwMKmCPtigwKBgQDiMWM41Ro+6a7uVSVE +YjhRO/6MV4YOZXhTv91LSZKmXGS6ADsYgwF9O70byXO17qsFQN/PYa4+nByK5Qa0 +dTd16KVIjtKk13cV+SQXM/vie0Ga3T9oWEJoXcPHPMRxdHqsM/O0YLZBbHJJmDdr +c0Nnl9kz8YROhHZo/IPHuNUZjwKBgQDG9tHUwB8AJru1wYF55iZZURD4JVG2c7/N +hEgRrW/C7e4AhPgk/tlDPKZ0d5fK2k8g8TUHlkRfwEDV/CklbUc2RHERrk1xIiQh +13a2uCWFHMWrkV3GYmhqoJ3LSY7pxWZnZU3jht07tSuDv7G6QrLVdE7VCZ5qCe74 +uM1d0I4fRwKBgCUkyxBoHjk6kplcrhP+tRTR70kIjmEK1KmcFjDo0gYzoe7RXuL1 +kGcKSlGn0TKNENR6BMa/Wae6Zw1/8ovru8HHBG1X1stu/oVDNmQBC1nzt6BAuMrf +w3fz/dRX7EJdSE/C8EAsqYEw18uyDVe9w9HdI7Pd80YZhoBTByji90uNAoGAKPnC +AxfCCqzvcaI1gR5V/YUFgqVk67dw4+l/Uiyu3K1Vm8PGuw/FPOSrv0a1+y/0pesn +KbXXxQR6FdfFvZCA3vs/cg28ozUNze5q43yPNZfUWd4pOucfa/5CIRC4HwrpaRfi +753hyB8qpAqBlsOPwwgeCB/gASSkk5KzsMN30ZcCgYEAvF6Ru/HjWOf19OSdD8ce +/QxqB6HWozdjvyxPxAWrk44V0HMCun3PzTeAi532lqNEYmZgYWg37gDBS7TC+TvV +mTQlD4nQ4wzKwoeQebgYPsKW2VO4wf414+rPxlp53avi9+BypUh6153X04o2R+x8 +RaIHpzPedwLMRmV6y8gsK1Q= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel3._domainkey.test2.dkim2.com.pem b/stevea/testdata/keys/sel3._domainkey.test2.dkim2.com.pem new file mode 100644 index 0000000..8a186fb --- /dev/null +++ b/stevea/testdata/keys/sel3._domainkey.test2.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4M5S4v+ssOLt+ +iF07D+yTfV4Z7kK1f1QTDmlJC/8TJjBb2XFmqL1LBTw5/7Pu4cswUmL5hthXbkJg +NSsMgTvVYsqi7cUvRbIfRTgOtmhHsIGcyNHP3YuqH5VXU8nPS3mZTxgPQ74knzSN +JwoghwLyvWrKxAj1jsR0gBYVmVhL3tzpaEpWD3jF44BpEOeluvzQfb2D2moazhiT +gdzWvk5em2PXawEw7skLP/h1DYOLQmw87ASNY1tX3KzPwRxJb/5GgUbkiiZ/j+r9 +pbtPq1Qap2PwzR5DRM7TFbSVziOopQcfzktt1kWFwMgFSDwwvqZGBJXLvv0zEw/v +67DeJUtBAgMBAAECggEADj//S2Odf+hLqPhHriAHP0m9k+f4xS26hVbiEkKZUxPr +Pc/OlBCNxFX9WMdNVjeljnFxMriZ1/mxrNuwHuGIjXN9FamrTYLoyr6CHbFC0/j1 +rfljMd+DxH95AC59OEoweAnEZLnq5c5jNGBGyklnkvvQXStZPhXzYMff7X/VctkD +SJWKCwSUYzGukD17SbOr9vUhTNrJG91SUFHNrTbQ5me40YKXIhKoT815trCZ4TXI +/juaBJtn66TvYsKDFizX8lVaPo8IHm/sVkj/tYAOqzhr/aW7G5aku5JYG9Y+BmWf +PHA7YuVaR+yQbdrvJEbnqZyi8ZP/RRz61QhC7q8zoQKBgQDaknG92f7YGLL1BHOQ +kKTF/gfIpQMvOBd7E37bvPe75PRYQZJ63QjPDDpUGMazyeHESsW8mdZ1rVfk9Nzu +L0pxlW2xw/RiE9KelV7NizFamCjNxiL8iQ2gzhf+F8VoHSgOLf7w1zjNao9FBxBV +Xz+6hZoSuNcU9Xrt6nRh6xY3fQKBgQDXvm6wz8BFzOVScNToCe4J2fq9h+Ml8tly +S8vD2gdMbhvTEmRB01p+xRET8R1HB5RzolclTXUJqkYEKMHAZXxkPOB0T65fqdgF +TXLB0DPhtqY2h3wlGCUwIRyhemGBpcMtJsDr85YJqfH5OgjKrtrsISVO9EJy8Id5 +EiKVMCYWFQKBgH1lbQntqk6SBQGa9y4mPdI1hoOvX99UP+xlZ+9rP3m1xhVoMUB5 +1RbsPf6HZGA7X2I9P6qPW8GRnI6HiMTBruTxRMiq0mb+VmSS75ve0obWKvq7RNE7 +U+1Ar/Uf8CpmJ9fSvaOw5i67hsykW9OO3MIG6jj5gLPOjiM20mRm+g0FAoGBAMku +Klg0kq5EQAAdeVwbjl6fZwMASWAk0oKTjaLHmJC8CLRN7TG43iVirOc0q0GGHvep +hawiS6ZEot/UBcDcoh9y6Tds6kUIw4lGTGHRPeAYC2zD8I9CscLszZ8C2RQluBhN +kA826U1/rUXjyTj/zuPskLbMbX5zL1FGCPunl4x1AoGARRDCgRDjPi2m8XjCXTEC ++7p2ngplNcrqTXODILFlARj448yd5y353BZzjlEITYDlLs61fsR4/GtYwdaiDWAJ +5Agw8PUvUWVy7/vMgxuaUDWczqLHxGL58Wb8Q0XbBoJluYYugQXx/O0HJ34lgerb +90UlpWYPqaeQbDyLdMf8Mq8= +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel3._domainkey.test3.dkim2.com.pem b/stevea/testdata/keys/sel3._domainkey.test3.dkim2.com.pem new file mode 100644 index 0000000..7ecbc8d --- /dev/null +++ b/stevea/testdata/keys/sel3._domainkey.test3.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCYlHU/GHF47Z5m +Uc9l+KQA7Kf/3muOzOgZZFm2L7RXKK1qlRW+Vr2egzfDStsURtXBbhjcgupjuPUL +WOIzTaiRtg1jATQILVZJMZStKjJFXucYUnmfq9wsIuoT0QUnAWdQIJcNPP73QPhq +JeBqyb8wAkkyQFDN2uulBPCcsZV9ZgZ6epRYnX7KfyReYOr/C5vSgTkbcMQTuegK +DGnOQR3q2oIgndnXBwD2qdSxMGOYMs8RkDBzhqPBn9x9dx4NeemtQqUW+r218xF4 +p85tPaDr+IGx6wEsKy5iAcpHJ02pPX5hTQaaRJCRe62DIfmjhbORSw+KbaxoiJOR +U9MK1tYHAgMBAAECggEASzom9IzgpXbCekAruWL1uV71Fbpb3XQ5mwtXD/RFPhPY +LIyEw+uQYe7gN3FYwo6KJWxa5Z3GYpR2fLm+DP1k9zKDNyUIul9gA4Zmf1omnv1D +g0JmpMrfKwOr1Ulg+PJANclbsDM6oj1uPSeflgcSNGFsJVKAhH5DYIEnAJqt0b0Q +YVcwnRyiaIXzenV2PpXYYZPBqsuDrRmJRyTnJg3ZkF/OOeURVCEnNTYO217wJKH5 +/i78G5dh8kZL+Dd4uWvnX/j8sBbT1Ke22+YgQ1O9QlSZLx4SC6Pv+8YCbJ9QcpxK +psJuEIYyZKhyAV4kL6IZI0vKU0tdK4p2IB4uyH8zgQKBgQDUU2fzFzu6T/9/J2VN ++I5bUrBV5U/49hGwM0M5LBDGMTi4Zv0Re+vFeUuvH8zykklTf6SJwmFBSH7Yu88d +ZQz2+zh9+25I5rRCWAM3xWQOOCeDKx9O0k3KBbR4eMHkNf6mMyjHJTNeBffLusdd +jzOH4zw6MPxDKp2NPBneGcUw+QKBgQC39vge6GpT7z7WVSUvuQEIB80PFVGL2xCO +yzOO/Ge92P7Hz2fG00JQCdgPwNDPn8lsGoBDisWygvOFMcB16FdkTwi4sAZfS6Lc +iS8LlYAzIzouOUkOnyhH54E+Y/bFJhJvcu8orgOMFMc0SqqNxc3i3+aKkNtyD4Z8 +1jzxyHT+/wKBgH48/L/eX2edqqW6EZQzJMiNOERJq6u0+b1OLTivx2Ve3H9e1DXx +/LMTM/lyKdNLQIeBi51QUayT4r8PosuySewKX4Mf22lCqYgMdhVRqfR+VJe7Kskd +Zpynma5K9dSuebHtFuCYcJsFZ1fcvC4XNyci5qseds2kZ7oSMrsHjWNZAoGAUWu/ +WX6oQMns19QLh1mseS9qtLhMxUeJxoltImXYTJBUA39qgPdTrDUPKbm/MYWZEn2Q +M1SiN2X5/gONGSJiO4U3aPaPe3HwfQdCiS/786Ytw/OawBv1Kg3uh/yaZZt6IVWP +QEP0UfWITT6WyfS4MSfD06RVW+A/N9ViV+jOz2UCgYBw2Mtnc8xKZLd8yGgpIxe/ +n0NaNjsBIi6Xvl3Qw+WRb18T/g1wgVXdQ1Cyuk1A4/4h8AuchXghsQZkWDnz5Df8 +f6F/TZdmZ4bIIoY9MFtGauDIgzKZ95t9svgvwlNAKeNbirMI7fnIVONw3yJWZCIE +ULK3ioozChUTvLGmZ293ag== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel3._domainkey.test4.dkim2.com.pem b/stevea/testdata/keys/sel3._domainkey.test4.dkim2.com.pem new file mode 100644 index 0000000..90664e5 --- /dev/null +++ b/stevea/testdata/keys/sel3._domainkey.test4.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCLLWZVJvqO3hji +bRXYzf979rNkFBQaNqGJ2ICAgT/hmsmfrlrNqB/dvJ8nBK/LFudRI2+8lF/hoSKS +0pU5Yqb0dsUwHxtL3vZSPoiG+I2tusjt92iz/C/tIHuUIRURMocLyWi3ubDfEPzp +EvOEEWUGmAqt5Cs3RtcYLu+QwsTk2jHGPUF1U4QvtFB/3o0aCLTySyC+U4wBwRWx +WsZgluuoBbzlAlh7/ShBNv0EHzqBGrB5pmmsU8MoHT9RSVEw01Hf5nCunuJtvxzR +BdoMnhX7mTYAHDwKn325cd4rwrnSd9qRCdjkRSVDyWkIgbYkcsdMXuzhNudclJPs +nLlFKY3LAgMBAAECggEABSyca9kFoq8eJLjigYtzDqRG2hcnAix6x/mtGVn0yonr +Kg0c4h14hdFf5170vCuvUORs4IlxoR1RAD1k/9urklYiEZ5ucN0qsjcR81alScqy +cqpqkZp0G7BD9+dc9aU3wRftVTtJmJl0laO3Ovc8lsirSPi9JJJ1J7iC/pJHKKzn ++LhD3C9cLTObzvPXAgBIP5bWoHJctlrZy+y1mvCfkuKtVPTEKH2RLHtZjW3ibri6 +rgGY/ZMSIxQi++rPQk4kIimGJrZ62s8GZkhBBRVE//L8rTK1Q+qT+cfSUfsqnugB +hHWYLrkpOpJ7eoVz5DKs7b6jGoFDy50/4Wj5ilJnzQKBgQC9/sqpMr+7yD3GZNKU +fFb03BEtxP8r6TR6ig1Uwn56cJs5MUi4UzKjKvLRONk34WYwQSklw3R9fa88I+b5 +Tol3qXudVRbK1jqJzxJiafIyOa0djlRLzQFB9kCsH22Dme3sZOkwflPpJmOYSEeJ +hjmK3WvLmdqDg/WM51X1R/ms1QKBgQC7hx7mLH1dfX9dPCC59Y3KZaYzJBf/Ja0b +4ubyNL1zhu0/6zDrTx4VtX7ZLkE+gHuQYGLkswSaEqERuUCZA7X5fb4PRD3NLEFc +ZGJjX9E7n+GgwG/lCa2Vt1c4RJk7rUHBkwfYv71w4yrd1OFmfr+bDmJ3Xh9C0JQn +Qov1ZIcgHwKBgAjnKpn5HdDv1i04xkFc/jolIczyQT7Og74GQA+ruX+FTVDFxXGV +vHa26X/yWQNsVXyHYSU63neO7yozSHyAOKLZ10gNF69TkJSqlmL0Mfw2ha0v4TVY +Gun7XxZxtpEHIoYESIUUNlxjhyE4qdmA56thKs70m6+z/EqpAHco80MtAoGAVQeL +hyqdHYsN+SSOYkNpK2Cz33R6PQLEX2Se+yvWy09evCDUaZ3SMl29SpSimMiBPpG1 +xaU2bPYNeo/ZLoh0NtBBIYL70tBCQrZxtkV4BeUU1WLsWZntz3j2X1kbcee5bSBR +6oHmUA1xHmysV2EZWCFJnH5dBlq1Eqgl9frDGlkCgYBdF7tg0H1yYKe2u40su38i +9s36M+DwKXImcLfNjiDqm2siRo6vl5JOzCE8GLqq/MXYhXHa8Sv0WOMJQdZ4G+IB +d4QC5ZKqhlHYbrWUowEdJJQY0sMRCgK73Qk2oxneyPy7OaisbbKiXkK3W0xDXr6H +QkTAqnLH2bhv3+Pt/B8KXA== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/keys/sel3._domainkey.test5.dkim2.com.pem b/stevea/testdata/keys/sel3._domainkey.test5.dkim2.com.pem new file mode 100644 index 0000000..606f4a9 --- /dev/null +++ b/stevea/testdata/keys/sel3._domainkey.test5.dkim2.com.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCxTsu5X1pm/yOI +1+VhAESkaMaPMoX48EEVJ7MsGRGxhDwgpyYJrS/8EjaELSHQMaq05vh3PrjQXehk +VHJRxfk+wqKebpIKGXWo6iDj7eg0WztuF7c0mbsxGBMJIqiYRYpcl6LfTfDL+IMq +2b4pQvyZIU2UCvcWdVPFerYtj6tQbvzodrvG8rs14s3j5HbbTrVBV/6upaEyBkHR +8QbMgBXHYLs/Z0IxPWxmg89cdqNebDfots5QOnXnW18fstD2p08v7TwViMzjGgl0 +vLeSAcBNvRud5urQx/HKbbHXicjpig7OoT59wvjElep9UhILVTK/HSd007qNqOwE +huvaKEp7AgMBAAECggEADYBpDgQfT7GFBLIgAutwzq411Iit3/93jUNZJvX/vWtK +4tenk1wPR9whL556MS7fB2iBKh9qFl/YRu+RRv3S1X8fv/2+fCtlJMgzWffvUzFR +TLhcmJJOSVZCeNMdUH8XIqbfXa7DM90IK/apvt2de5GoJSpSl3C2wNZv0fdbPcQd +PHu5Kz0RiXGVdc2gBBr0SPJgeTkbZ8p6bll5lhetZjAve0JPcPwJmM+BHNKqHA4i +4CNcey1SCEupIE4PK+hcLWpJJd9pTrpQgEWV6377fdwdk5d1kVGnpU45HWVk/Mhc +6KbxLu8wUroEXKb26/AA/aO8P9/tBvPw99vl33czpQKBgQDrnde3bu5r4zcJ9Lzv +l+Gi0XkTg3RL36LJ0fM6pVgd2FOcBU3pLm3KAJPCYwGPpaxxhE/PVeETKREHgUgi +flS/+Ph+v1ct7HfAmAADoiv1oQU8xIR3BTW32sjKPaHRbIr8wddQs1kvNxqYz5aZ +ikETtrcsDlObvdlJWj3u+qJ2dwKBgQDApZlq8NHezSMw2DHFgEQxjdH4WNfjupRl +KDASgQQI+LDa0xq48qC3+gCszPFMHKn6IyPKSyCQIriPErlmELnD/FMszEcN1fOA +nkCavuBvwg1wHOp1mhvpF+TMvaj0K6Vxquu+CgDEqHjt3ujqntW/R1q6WXzRE6DA +6k+rktrZHQKBgGZj8ZqFWpcH5qMs9+4m0qcu4K7aMW/Hf7a0xj52HBTcLXlf1LvA +sXVXSt7up38FQCsUlJpFd8obzoq0Y+SZgbrrNxHdlMUgPeo0I81wbCoSD3SY8ffH +C9EaAqfgoLGhucSdeDRZvCgIikZd79RJgH5QqMk8cJj9p694x/FSeceBAoGAGKpb +PUT+Kf+r3MohfT8R/CJDWv/NDHxMGbOOjB/2q0tkIXBcAIgYKdYZlgZbcrpMzRkI +sRLzZWD8rlzgXgAQONaqs5aoFk5pcfNRJD8CL2zyGYOqRUpo/mwvwEMcSb743Nfc +fSV8ubE0Yohi2/9gMLBrHmbswzw3HdwiPT59RMUCgYBaPrUUjHtlqwK4gZzm7Vgg +XfcyrM62EiHjBRPzA2SWuneJrHe+a2S3vkJWYA7bcArvHRTPlyPudMb3jUetJpm+ +niPNsF/XfDg5xniK2N9mMZNrgvvBDrn2LQnSPx9g01K050N8GO9SYlWOclQ5DjzH +CmpzQWjnFl358FQkncvC8g== +-----END PRIVATE KEY----- diff --git a/stevea/testdata/recipe/cannot_recreate.in b/stevea/testdata/recipe/cannot_recreate.in new file mode 100644 index 0000000..a71480f --- /dev/null +++ b/stevea/testdata/recipe/cannot_recreate.in @@ -0,0 +1,3 @@ +Subject: one +Comment: two +Subject: three diff --git a/stevea/testdata/recipe/cannot_recreate.json b/stevea/testdata/recipe/cannot_recreate.json new file mode 100644 index 0000000..a908c84 --- /dev/null +++ b/stevea/testdata/recipe/cannot_recreate.json @@ -0,0 +1,3 @@ +{ + "h": {} +} \ No newline at end of file diff --git a/stevea/testdata/recipe/cannot_recreate.want b/stevea/testdata/recipe/cannot_recreate.want new file mode 100644 index 0000000..40a9149 --- /dev/null +++ b/stevea/testdata/recipe/cannot_recreate.want @@ -0,0 +1 @@ + diff --git a/stevea/testdata/recipe/copy_one.in b/stevea/testdata/recipe/copy_one.in new file mode 100644 index 0000000..a71480f --- /dev/null +++ b/stevea/testdata/recipe/copy_one.in @@ -0,0 +1,3 @@ +Subject: one +Comment: two +Subject: three diff --git a/stevea/testdata/recipe/copy_one.json b/stevea/testdata/recipe/copy_one.json new file mode 100644 index 0000000..cda8456 --- /dev/null +++ b/stevea/testdata/recipe/copy_one.json @@ -0,0 +1,8 @@ +{ + "h": { + "subject": [ + { "c": [2, 2]} + ], + "comment": [] + } +} \ No newline at end of file diff --git a/stevea/testdata/recipe/copy_one.want b/stevea/testdata/recipe/copy_one.want new file mode 100644 index 0000000..55a334d --- /dev/null +++ b/stevea/testdata/recipe/copy_one.want @@ -0,0 +1 @@ +subject: one diff --git a/stevea/testdata/recipe/no_change.in b/stevea/testdata/recipe/no_change.in new file mode 100644 index 0000000..a71480f --- /dev/null +++ b/stevea/testdata/recipe/no_change.in @@ -0,0 +1,3 @@ +Subject: one +Comment: two +Subject: three diff --git a/stevea/testdata/recipe/no_change.json b/stevea/testdata/recipe/no_change.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/stevea/testdata/recipe/no_change.json @@ -0,0 +1 @@ +{} diff --git a/stevea/testdata/recipe/no_change.want b/stevea/testdata/recipe/no_change.want new file mode 100644 index 0000000..bf7680f --- /dev/null +++ b/stevea/testdata/recipe/no_change.want @@ -0,0 +1,3 @@ +comment: two +subject: one +subject: three diff --git a/stevea/testdata/recipe/preserve_unmentioned.in b/stevea/testdata/recipe/preserve_unmentioned.in new file mode 100644 index 0000000..a71480f --- /dev/null +++ b/stevea/testdata/recipe/preserve_unmentioned.in @@ -0,0 +1,3 @@ +Subject: one +Comment: two +Subject: three diff --git a/stevea/testdata/recipe/preserve_unmentioned.json b/stevea/testdata/recipe/preserve_unmentioned.json new file mode 100644 index 0000000..914bcfe --- /dev/null +++ b/stevea/testdata/recipe/preserve_unmentioned.json @@ -0,0 +1,5 @@ +{ + "h": { + "comment": [] + } +} \ No newline at end of file diff --git a/stevea/testdata/recipe/preserve_unmentioned.want b/stevea/testdata/recipe/preserve_unmentioned.want new file mode 100644 index 0000000..c2ae8ea --- /dev/null +++ b/stevea/testdata/recipe/preserve_unmentioned.want @@ -0,0 +1,2 @@ +subject: one +subject: three diff --git a/stevea/testhelpers_test.go b/stevea/testhelpers_test.go new file mode 100644 index 0000000..bd6a280 --- /dev/null +++ b/stevea/testhelpers_test.go @@ -0,0 +1,156 @@ +package dkim2 + +import ( + "bytes" + "context" + "crypto" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/mail" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" +) + +type TestResolver struct { + jsonFile string +} + +func (t TestResolver) Resolve(_ context.Context, selector string, domain string) ([]string, error) { + records := sync.OnceValue( + func() map[string]map[string][][]string { + f, err := os.Open(t.jsonFile) + if err != nil { + panic(err) + } + dec := json.NewDecoder(f) + result := map[string]map[string][][]string{} + err = dec.Decode(&result) + if err != nil { + panic(err) + } + return result + })() + dom, ok := records[domain] + if !ok { + return []string{}, nil + } + rec, ok := dom[selector+"._domainkey"] + if !ok { + return []string{}, nil + } + var results []string + for _, rec := range rec { + results = append(results, rec[1]) + } + return results, nil +} + +var _ KeyResolver = TestResolver{} + +func NewTestResolver(file ...string) TestResolver { + if len(file) > 0 { + return TestResolver{ + jsonFile: file[0], + } + } + + return TestResolver{ + jsonFile: filepath.Join("testdata", "dns.json"), + } +} + +func loadPrivateKey(t testing.TB, name string) crypto.Signer { + filename := filepath.Join("testdata", "keys", name) + keyContent, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + key, err := parsePrivateKey(string(keyContent)) + if err != nil { + t.Fatal(err) + } + return key +} + +func parsePrivateKey(key string) (crypto.Signer, error) { + keyfile := []byte(key) + pemBlock, _ := pem.Decode(keyfile) + if pemBlock != nil && pemBlock.Type == "PRIVATE KEY" { + key, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, err + } + + switch key := key.(type) { + case *rsa.PrivateKey: + return key, nil + case ed25519.PrivateKey: + return key, nil + } + } + + return nil, fmt.Errorf("failed to parse private key") +} + +func loadEmail(t testing.TB, filename ...string) ([]byte, *mail.Message) { + content, err := os.ReadFile(filepath.Join(filename...)) + if err != nil { + t.Fatal(err) + } + //fmt.Printf("%q\n", content) + msg, err := mail.ReadMessage(bytes.NewReader(content)) + if err != nil { + t.Fatal(err) + } + return content, msg +} + +func splitTags(h string) (map[string]string, error) { + ret := map[string]string{} + h = strings.ReplaceAll(h, "\r\n", "") + for kv := range strings.SplitAfterSeq(h, ";") { + if strings.TrimSpace(kv) == "" { + continue + } + k, v, found := strings.Cut(kv, "=") + if !found { + return nil, fmt.Errorf("no \"=\" found in %q", kv) + } + v = strings.TrimSuffix(v, ";") + v = strings.TrimSpace(v) + + k = strings.TrimSpace(k) + k = strings.ToLower(k) + ret[k] = v + } + return ret, nil +} + +func diffTags(t testing.TB, want, got []string) string { + var wants, gots []map[string]string + for _, w := range want { + wantTags, err := splitTags(w) + if err != nil { + t.Fatal(err) + } + wants = append(wants, wantTags) + } + + for _, g := range got { + gotTags, err := splitTags(g) + if err != nil { + t.Fatal(err) + } + gots = append(gots, gotTags) + } + return cmp.Diff(wants, gots) +} diff --git a/stevea/time.go b/stevea/time.go new file mode 100644 index 0000000..3616bc6 --- /dev/null +++ b/stevea/time.go @@ -0,0 +1,8 @@ +package dkim2 + +import "time" + +// Now returns the current unix epoch time. +var Now = func() int64 { + return time.Now().Unix() +} diff --git a/stevea/tools/base64bytes/base64bytes.go b/stevea/tools/base64bytes/base64bytes.go new file mode 100644 index 0000000..0185c97 --- /dev/null +++ b/stevea/tools/base64bytes/base64bytes.go @@ -0,0 +1,22 @@ +package main + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "os" +) + +func main() { + encoded, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatal(err) + } + dst := make([]byte, base64.StdEncoding.DecodedLen(len(encoded))) + n, err := base64.StdEncoding.Decode(dst, encoded) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%#v\n", dst[:n]) +} diff --git a/stevea/verify.go b/stevea/verify.go new file mode 100644 index 0000000..3e32368 --- /dev/null +++ b/stevea/verify.go @@ -0,0 +1,950 @@ +package dkim2 + +//go:generate go run internal/generate/validationerrors/validationerrors.go --label=error_names.txt --output=verify_errors.go + +import ( + "bytes" + "cmp" + "context" + "crypto" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/mail" + "regexp" + "slices" + "strings" +) + +// KeyResolver fetches a public key for a selector / domain +// pair, typically by performing a DNS query. +type KeyResolver interface { + // Resolve returns DNS-style results for a selector + // domain pair. A NOERROR or NXDOMAIN response will + // return a string slice of TXT record payloads. + // Anything else will return an error. + Resolve(ctx context.Context, selector string, domain string) ([]string, error) +} + +// ErrVerify is an error that represents a failure +// to verify a DKIM2 signed message. It is a error, +// but is intended to also be used in user-facing +// rejections, bounces and Authentication-Results headers. +type ErrVerify interface { + error + State() VerificationState +} + +// VerificationState represents the broad pass/fail +// result of a verification, using the RFC 8601 terms. +type VerificationState string + +const ( + StatePass VerificationState = "pass" + StateFail VerificationState = "fail" + StateTempError VerificationState = "temperror" + StatePermError VerificationState = "permerror" +) + +// VerificationResult contains the result of verifying +// a DKIM2 signed email. +type VerificationResult struct { + Err ErrVerify + Domain string + D2I int + Selector string + Exploded bool + Feedback bool + Flags map[string]struct{} +} + +func newResult(err error) *VerificationResult { + res := &VerificationResult{ + Flags: make(map[string]struct{}), + } + if err != nil { + res.setError(err) + } + return res +} + +// State returns whether the email was verified or not, +// using RFC 8601 states. +func (v *VerificationResult) State() VerificationState { + if v.Err == nil { + return StatePass + } + return v.Err.State() +} + +// AuthenticationResult returns a string description of +// the result, suitable for use in an Authentication-Results +// header. +func (v *VerificationResult) AuthenticationResult() string { + if v.Err == nil { + return "; dkim2=pass" + } + var builder strings.Builder + _, _ = fmt.Fprintf(&builder, "; dkim2=%s (%s)", v.State(), v.Err.Error()) + if v.Domain != "" { + _, _ = fmt.Fprintf(&builder, "; header.d=%s", v.Domain) + } + if v.Selector != "" { + _, _ = fmt.Fprintf(&builder, " header.s=%s", v.Selector) + } + if v.D2I != 0 { + _, _ = fmt.Fprintf(&builder, " header.i=%d", v.D2I) + } + return builder.String() +} + +// ErrInternal wraps a Go error that happened during +// message processing. +type ErrInternal struct { + Err error +} + +func (e ErrInternal) Error() string { + return "internal error" +} + +func (e ErrInternal) State() VerificationState { + return StateFail +} + +func (e ErrInternal) Unwrap() error { + return e.Err +} + +func (v *VerificationResult) setError(err error) { + if err == nil { + return + } + e, ok := err.(ErrVerify) + if ok { + v.Err = e + return + } + v.Err = ErrInternal{ + Err: err, + } +} + +type VerifyOptions struct { + IgnoreTimestamp bool + Resolver KeyResolver + MailFrom string + RcptTo []string +} + +func (opts *VerifyOptions) validate() error { + if opts.Resolver == nil { + if DefaultResolver != nil { + opts.Resolver = DefaultResolver + } else { + var err error + opts.Resolver, err = NewDnsResolver("") + if err != nil { + return err + } + } + } + return nil +} + +var DefaultResolver = func() KeyResolver { + r, err := NewDnsResolver("") + if err != nil { + return nil + } + return r +}() + +//XXXX do chain-walking VerifyAll, generating lists +//of all D2, MI headers and applying the recipes as we +//walk back D2, removing sigs and associated MI as we +//go. Have a struct that contains the header lists, +//a mail.Header and body, and a function that steps +//that back to a given point. Use that for dkim2undo, +//and VerifyAll. + +// Verify verifies the newest DKIM2 signature of a message, +// as required at email receipt time to allow rejection +// if needed. +func Verify(ctx context.Context, r io.Reader, opts VerifyOptions) *VerificationResult { + err := opts.validate() + if err != nil { + return newResult(err) + } + + msg, err := mail.ReadMessage(r) + if err != nil { + return newResult(err) + } + // Parse all the DKIM2-Signature headers + d2headers, miHeaders, err := parseD2Headers(msg.Header) + if err != nil { + return newResult(err) + } + result, err := verifyMessage(ctx, msg, opts.MailFrom, opts.RcptTo, d2headers, miHeaders, opts) + if err != nil { + if result == nil { + result = newResult(nil) + } + result.setError(err) + } + return result +} + +// VerifyAll verifies all DKIM2 signatures of a message. +func VerifyAll(ctx context.Context, r io.Reader, opts VerifyOptions) *VerificationResult { + err := opts.validate() + if err != nil { + return newResult(err) + } + + msg, err := mail.ReadMessage(r) + if err != nil { + return newResult(err) + } + // Parse all the DKIM2-Signature headers + d2headers, miHeaders, err := parseD2Headers(msg.Header) + if err != nil { + return newResult(err) + } + + body, err := io.ReadAll(msg.Body) + if err != nil { + return newResult(err) + } + headers := NormalizedHeaders(msg.Header) + result := newResult(nil) + + // TODO(steve): Check mailfrom / rcptto chains + + // For each signature + for len(d2headers) > 0 { + lastD2 := d2headers[len(d2headers)-1] + // Verify the signature for the state of the + // message at the time it was assigned + res, err := verifyMessage(ctx, &mail.Message{ + Header: headers, + Body: bytes.NewReader(body), + }, "", nil, d2headers, miHeaders, opts) + if err != nil { + res.setError(err) + return res + } + if res.Exploded { + result.Exploded = true + } + if res.Feedback { + result.Feedback = true + } + for flag := range res.Flags { + result.Flags[flag] = struct{}{} + } + + // Revert changes recorded in Message-Instance + // headers + m := lastD2.MIRevision + for len(miHeaders) > 0 && + miHeaders[len(miHeaders)-1].Revision >= m { + recipe := miHeaders[len(miHeaders)-1].Recipes + if recipe != nil { + headers, err = recipe.Header(headers) + if err != nil { + res.setError(err) + return res + } + var buff bytes.Buffer + err = recipe.Body(bytes.NewReader(body), &buff) + if err != nil { + res.setError(err) + return res + } + body = buff.Bytes() + } + miHeaders = miHeaders[:len(miHeaders)-1] + } + d2headers = d2headers[:len(d2headers)-1] + } + + return result +} + +// parseD2Headers parses the Messsage-Instance and +// DKIM2-Signature headers from a mail.Header and returns +// them as two lists in ascending i=/m= order +func parseD2Headers(header mail.Header) ([]*Signature, []*MessageInstance, error) { + d2RawHeaders := header[hdrDKIM2Signature] + if len(d2RawHeaders) == 0 { + // Bail early, so we don't need to deal with this + // case later + return nil, nil, ErrSignatureMissing{ + Sequence: 1, + } + } + + d2headers := make([]*Signature, 0, len(d2RawHeaders)) + + maxM := 0 + for _, h := range d2RawHeaders { + d2, err := ParseSignature(h) + if err != nil { + return nil, nil, err + } + + d2headers = append(d2headers, d2) + if d2.MIRevision > maxM { + maxM = d2.MIRevision + } + } + + // Sort the DKIM2-Signature headers by i=, then + // we can check for duplicates and missing values. + slices.SortFunc(d2headers, func(a, b *Signature) int { + return cmp.Compare(a.Sequence, b.Sequence) + }) + + for n, d2 := range d2headers { + if d2.Sequence == n+1 { + continue + } + if d2.Sequence == n { + // duplicate i= + return nil, nil, ErrSignatureSyntaxError{ + Sequence: d2.Sequence, + Header: d2.Original, + } + } + return nil, nil, ErrSignatureMissing{ + Sequence: n + 1, + } + } + + // Parse all the Message-Instance headers + miRawHeaders := header[hdrMessageInstance] + miHeaders := make([]*MessageInstance, 0, len(miRawHeaders)) + for _, h := range miRawHeaders { + mi, err := ParseMessageInstance(h) + if err != nil { + return nil, nil, err + } + miHeaders = append(miHeaders, mi) + if mi.Revision > maxM { + return nil, nil, ErrMessageInstanceIsNotSigned{ + MInstance: mi.Revision, + } + } + } + + // Sort the Message-Instance headers by i=, then + // we can check for duplicates and missing values. + slices.SortFunc(miHeaders, func(a, b *MessageInstance) int { + return cmp.Compare(a.Revision, b.Revision) + }) + + for n, mi := range miHeaders { + if mi.Revision == n+1 { + continue + } + if mi.Revision == n { + // duplicate m= + return nil, nil, ErrMessageInstanceSyntaxError{ + MInstance: mi.Revision, + Header: mi.Original, + } + } + return nil, nil, ErrMessageInstanceMissing{ + MInstance: n + 1, + } + } + return d2headers, miHeaders, nil +} + +var replaceSignatureRe = regexp.MustCompile(`((?:^|;)s=)[^;]+`) + +// verifyMessage validates the most recent DKIM2-Signature for a message. +// It may read some or all of msg.Body. +func verifyMessage(ctx context.Context, msg *mail.Message, + mailFrom string, rcptTo []string, d2headers []*Signature, + miHeaders []*MessageInstance, opts VerifyOptions) (*VerificationResult, error) { + result := &VerificationResult{} + + // 10.3 + // Reject anything with a timestamp more than 14 days old + timeStampStale := Now() - 3600*14*24 + for _, d2 := range d2headers { + if d2.Timestamp < timeStampStale && !opts.IgnoreTimestamp { + return result, ErrSignatureExpired{ + Sequence: d2.Sequence, + } + } + } + + // 10.4 Check the chain of custody + lastD2 := d2headers[len(d2headers)-1] + result.Domain = lastD2.Domain + result.D2I = lastD2.Sequence + result.Exploded = lastD2.Exploded + result.Feedback = lastD2.Feedback + for _, flag := range lastD2.Flags() { + result.Flags[flag] = struct{}{} + } + if rcptTo != nil { + // MAIL FROM must match value in signature + if foldEmailDomain(mailFrom) != + foldEmailDomain(lastD2.MailFrom) { + return result, ErrMailFromValueDidNotMatch{ + Value: mailFrom, + } + } + + // All RCPT TO values must match a value in signature + allowedRcptTo := make(map[string]struct{}, len(lastD2.RcptTo)) + for _, rcptTo := range lastD2.RcptTo { + allowedRcptTo[foldEmailDomain(rcptTo)] = struct{}{} + } + for _, rcptTo := range rcptTo { + _, ok := allowedRcptTo[foldEmailDomain(rcptTo)] + if !ok { + return result, ErrRcptToValueDidNotMatch{ + Value: rcptTo, + } + } + } + + // The MAIL FROM domain must match the d= signing + // domain, either exactly or as a subdomain of it. + if mailFrom != "<>" { + at := strings.LastIndex(mailFrom, "@") + mailFromDomain := "." + strings.ToLower(mailFrom[at+1:]) + signingDomain := "." + strings.ToLower(lastD2.Domain) + if !strings.HasSuffix(mailFromDomain, signingDomain) { + return result, ErrMailFromAndDoNotMatch{} + } + } + } + // 8.5 Generate sorted Message-Instance and + // DKIM2-Signature headers for hashing. + var buff bytes.Buffer + for _, mi := range miHeaders { + buff.WriteString(lwrMessageInstance) + buff.WriteString(":") + buff.WriteString(removeWhitespace(mi.Original)) + buff.WriteString("\r\n") + } + + for _, d2 := range d2headers[:len(d2headers)-1] { + buff.WriteString(lwrDKIM2Signature) + buff.WriteString(":") + buff.WriteString(removeWhitespace(d2.Original)) + buff.WriteString("\r\n") + } + + finalD2 := removeWhitespace(lastD2.Original) + finalD2 = replaceSignatureRe.ReplaceAllStringFunc(finalD2, func(s string) string { + sigs := strings.Split(s, ",") + modified := make([]string, len(sigs)) + for i, sig := range sigs { + // Split into selector:name:signature + // parts[0] will also contain any leading "s=" + parts := strings.Split(sig, ":") + if len(parts) != 3 { // :shrug: + modified[i] = sig + continue + } + modified[i] = parts[0] + ":" + parts[1] + ":" + } + return strings.Join(modified, ",") + }) + buff.WriteString(lwrDKIM2Signature) + buff.WriteString(":") + buff.WriteString(finalD2) + buff.WriteString("\r\n") + signedHeaders := buff.Bytes() + + // Check signatures + sigErrors := make([]error, len(lastD2.Signatures)) + errCount := 0 + for i, sig := range lastD2.Signatures { + err := verifySignature(ctx, lastD2, sig, signedHeaders, opts.Resolver) + sigErrors[i] = err + if err != nil { + errCount++ + } + } + if errCount > 0 { + selNames := make([]string, len(lastD2.Signatures)) + for i, sig := range lastD2.Signatures { + selNames[i] = sig.Selector + } + if errCount == len(sigErrors) { + if errCount == 1 { + return result, sigErrors[0] + } + fails := make([]string, len(sigErrors)) + for i, sig := range sigErrors { + fails[i] = sig.Error() + } + return result, ErrFailSignatureValidationFailed{ + Sequence: lastD2.Sequence, + Detail: strings.Join(fails, ", "), + } + } + passFail := make([]string, 0, len(lastD2.Signatures)) + for i, sig := range lastD2.Signatures { + if sigErrors[i] == nil { + passFail[i] = sig.Selector + " passed" + } else { + passFail[i] = fmt.Sprintf("%s failed (%s)", sig.Selector, sigErrors[i].Error()) + } + } + return result, ErrFailSignatureValidationFailed{ + Sequence: lastD2.Sequence, + Detail: strings.Join(passFail, ", "), + } + } + // All signatures passed, time to validate the hashes + + lastMI := miHeaders[len(miHeaders)-1] + seenSHA256 := false + for _, hash := range lastMI.Hashes { + if hash.Name == "sha256" { + seenSHA256 = true + } + } + if !seenSHA256 { + // No sha256 hash in Message-Instance + return result, ErrMessageInstanceSyntaxError{ + MInstance: lastMI.Revision, + Header: lastMI.Original, + Detail: "missing required sha256 hash", + Err: errors.New("no sha256 hash found"), + } + } + + hashes, err := HashMessage(msg.Body, NormalizedHeaders(msg.Header)) + if err != nil { + // Currently can't happen. + return result, err + } + + for _, hashFromHeader := range lastMI.Hashes { + for _, hash := range hashes { + if hash.Name == hashFromHeader.Name { + if !bytes.Equal(hashFromHeader.HeaderHash, hash.HeaderHash) { + return result, ErrFailMessageInstanceHeaderHashValueMismatch{ + MInstance: lastMI.Revision, + Value: hash.String(), + } + } + if !bytes.Equal(hashFromHeader.BodyHash, hash.BodyHash) { + return result, ErrFailMessageInstanceBodyHashValueMismatch{ + MInstance: lastMI.Revision, + Value: hash.String(), + } + } + } + } + } + return result, nil +} + +func verifySignature(ctx context.Context, lastD2 *Signature, sig Sig, signedHeaders []byte, resolver KeyResolver) error { + txtRecords, err := resolver.Resolve(ctx, sig.Selector, lastD2.Domain) + if err != nil { + return ErrTempSignaturePublicKeyValueCouldNotBeFetched{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: err, + } + } + if len(txtRecords) == 0 { + return ErrSignaturePublicKeyValueDoesNotExist{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + if len(txtRecords) > 1 { + return ErrSignaturePublicKeyValueHasMultipleRecords{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + + var algorithm, key string + for i, kv := range strings.Split(txtRecords[0], ";") { + k, v, found := strings.Cut(kv, "=") + if !found { + if strings.TrimSpace(kv) == "" { + continue + } + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: fmt.Errorf("no \"=\" found in %q", kv), + } + } + k = strings.ToLower(strings.TrimSpace(k)) + + v = strings.TrimSpace(v) + switch k { + case "v": + if i != 0 { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: "\"v=\" must be first field", + Err: fmt.Errorf("\"v=\" must be first field and equal \"DKIM1\""), + } + } + if v != "DKIM1" { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: fmt.Sprintf("\"v=%s\" should be \"v=DKIM1\"", v), + Err: fmt.Errorf("\"v=\" equal \"DKIM1\""), + } + } + case "k": + switch v { + case "rsa": + if sig.Name != "rsa-sha256" { + return ErrSignaturePublicKeyValueAlgorithmMismatch{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + case "ed25519": + if sig.Name != "ed25519-sha256" { + return ErrSignaturePublicKeyValueAlgorithmMismatch{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + default: + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: fmt.Sprintf("\"k=%s\" not supported", k), + Err: fmt.Errorf("unrecognized \"k=\": %q", v), + } + } + algorithm = v + case "p": + if v == "" { + return ErrSignaturePublicKeyValueHasBeenRevoked{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + key = removeWhitespace(v) + } + } + rawKey, err := base64.StdEncoding.Strict().DecodeString(key) + if err != nil { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: err, + } + } + switch algorithm { + case "rsa": + // See https://www.rfc-editor.org/errata/eid3017 + pub, err := x509.ParsePKIXPublicKey(rawKey) + if err != nil { + pub, err = x509.ParsePKCS1PublicKey(rawKey) + if err != nil { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: err, + } + } + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: "not an RSA public key", + Err: fmt.Errorf("expected type *rsa.PublicKey, got %T", pub), + } + } + if rsaPub.Size()*8 < 1024 { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: fmt.Sprintf("RSA public key size too small, %d bits", rsaPub.Size()*8), + Err: fmt.Errorf("key is too short: want 1024 bits, has %v bits", rsaPub.Size()*8), + } + } + hashed := sha256.Sum256(signedHeaders) + err = rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hashed[:], sig.Signature) + if err != nil { + return ErrFailSignaturePublicKeyValueIncorrectSignature{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: err, + } + } + case "ed25519": + if len(rawKey) != ed25519.PublicKeySize { + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Detail: fmt.Sprintf("invalid ed25519 key size, %d bytes", len(rawKey)), + } + } + hashed := sha256.Sum256(signedHeaders) + ed25519Pub := ed25519.PublicKey(rawKey) + //fmt.Printf("signedHeaders:\n---\n%s\n---\nhash: %x\nkey: %x\n", signedHeaders, hashed, rawKey) + if !ed25519.Verify(ed25519Pub, hashed[:], sig.Signature) { + return ErrFailSignaturePublicKeyValueIncorrectSignature{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + } + } + default: + return ErrSignaturePublicKeyValueHasASyntaxError{ + Sequence: lastD2.Sequence, + Value: HostnameForKey(sig.Selector, lastD2.Domain), + Err: fmt.Errorf("unexpected signature algorithm: %q", algorithm), + } + } + + return nil +} + +func foldEmailDomain(s string) string { + at := strings.LastIndex(s, "@") + if at == -1 { + return s + } + return s[:at+1] + strings.ToLower(s[at+1:]) +} + +/* +# Verifier Actions {#verifier_actions} + +This section discusses the detail of the actions taken by a +Verifier. In essence +this will involve repeating all the actions taken by a Signer to +produce a Message-Instance or DKIM2-Signature header field. To +avoid a lot of repetition these actions will not be spelled out +in detail. Once a hash value has been calculated it is then +compared with the value reported by the Signer, or the Signer's +public key is used to determine whether a signature that has +been provided is correct. + +When a Verifier is determining whether a particular DKIM2-Signature +header field it MUST consider the state of the message when that +header field was added to the message. That means it MUST first apply +all relevant recipes to reconstruct the body and header fields and it +MUST ignore any Message-Instance and DKIM2-Signature fields that +were added after that point. + +## Output States + +For compatibility with the Authentication-Results header field defined +in [RFC8601] a verification will result in one of four states: + +PASS: The message was successfully verified. + +FAIL: The message could be verified but a hash or signature was not + correct. + +PERMERROR: The message could not be verified due to some error that + is unrecoverable, such as a required header field being absent + or malformed. + +TEMPERROR: The message could not be verified due a temporary + inability to retrieve a public key. A later attempt may + produce a different. + +A Verifier MAY cease verifying once a single failure is detected. + +Verifiers wishing to communicate the results of verification to other +parts of the mail system may do so in whatever manner they see fit. If +they wish to provide a human-readable string to describe a failure +to verify (any state except PASS) then in order to provide the +maximum possible assistance to senders they SHOULD use the text +strings specified in this document. These human-readable messages +are described with m=`` or tag=`` placeholders, the `` and `` MUST +be replaced with the relevant ordinal or tag name (without the < and +> characters). Similarly `` MUST be replaced by a relevant +string for the particular message. + +If the verification is being performed during an SMTP protocol +conversation the human-readable string SHOULD be part of the +5xx or 4xx response string. + +If the results of the verification are being communicated in a +Delivery Status Notification message ({{RFC3461}}) the +human-readable string should be included. + +If, by local policy, a system wishes to accept a message which +has failed authentication it might choose to add an email header +field to the message before passing it on. Any such header field +SHOULD include the human-readable string and +SHOULD be inserted before any existing DKIM2-Signature or pre-existing +authentication status header fields in the header field block. The +Authentication-Results: header field ([RFC8601]) MAY be used for this +purpose. It should be noted that any "Authentication-Results" header +field will count as a modification to the email if any further +DKIM2-Signature header fields are to be generated. + +## Ensure that the DKIM2 Header Fields are Valid + +Verifiers MUST meticulously validate the format and values of all +relevant Message-Instance and DKIM2-Signature header fields. It MUST +also ensure that all required instances of these header fields are +present and that all required tags are present. Recall however +that unknown tags MUST be ignored. + +As a special case, there MUST not be a Message-Instance field +with a higher m= value than occurs in any DKIM2-Signature field. + +Possible errors: + + PERMERROR Message-Instance m= missing + PERMERROR Message-Instance m= syntax error + PERMERROR Message-Instance m= tag= missing + PERMERROR Message-Instance m= is not signed + PERMERROR DKIM2-Signature i= missing + PERMERROR DKIM2-Signature i= syntax error + PERMERROR DKIM2-Signature i= tag= missing + +## Check the timestamps + +Verifiers SHOULD return a failure it is more than 14 days since the +timestamp recorded in the "t=" tag of any DKIM2-Signature header field. + +Possible errors: + + PERMERROR DKIM2-Signature i= signature expired + +## Check the Chain-of-Custody + +As explained in {{chain-of-custody}} a Verifier MUST check an exact +match between the MAIL FROM and RCPT TO parameters used when delivering +a message and the values found in the mf= and rt= tags of the highest +numbered DKIM2-Signature header field. There may be extra values +in the rt= value, but all RCPT TO values actually used for +delivery MUST be present. + +The values of domains MUST BE put into lower-case before doing these +checks. As is usual in email protocols the case of the local part of +an email address is assumed to matter. Note that these checks MUST NOT +use the relaxed domain match algorithm. + +A Verifier SHOULD check that there is a relaxed domain match +(see {relaxed-domain-match}) between the signing domain of the +most recently applied DKIM2-Signature header field and the +mf= value in that header field. + +Possible errors: + + PERMERROR: MAIL FROM did not match + PERMERROR: RCPT TO did not match + PERMERROR: MAIL FROM and d= do not match + +## Fetch the Public Key + +The public keys of all the signatures in DKIM2-Signature fields are +needed to complete the verification process. Details of key management and +representation are described in {{key_management}} and [DKIMKEYS]. +The Verifier MUST validate the key record and MUST not use any public +key records that are malformed. + +Note that DNS timeouts MUST be reported as TEMPERROR but a DNS +result that indicates the key is absent MUST be reported as a +PERMERROR. Additionally, as [DKIMKEYS] makes clear, if more than +one record is returned this is an error. The human-readable error +message SHOULD provide the selector value so that it is clear which +key has caused a problem. + +Note that [DKIMKEYS] has retired the h= field and DKIM2 implementations +MUST ignore this tag if it is present. + +Possible errors: + + TEMPERROR: DKIM2-Signature i= public key could not be fetched + PERMERROR: DKIM2-Signature i= public key does not exist + PERMERROR: DKIM2-Signature i= public key has multiple records + PERMERROR: DKIM2-Signature i= public key has a syntax error + PERMERROR: DKIM2-Signature i= public key algorithm mismatch + PERMERROR: DKIM2-Signature i= public key has been revoked + +## Perform the Signature Verification Calculation + +Verifying a signature consists of actions semantically equivalent to the +following steps: + +1. Prepare a canonicalized version of the Message-Instance and DKIM2-Signature + header fields as described in {{calculate-signature}}. The signature value(s) + themselves will need to be removed to correspond with what was actually + signed. Note that this canonicalized version does not actually replace + the original content. + +1. Use the relevant public key value(s) to check the signature(s). + +1. If there is more than one signature provided then they MUST all be + checked if the Verifier is able to do so. If any signature fails then + an error SHOULD be reported. If all signatures that can be checked fail + then PERMFAIL MUST be reported. + +1. If some signatures fail and other pass then any error that is + reported should provide that information (e.g. PERMFAIL "rsa-sha256 + signature passed, ed25519-sha256 signature failed"). + +The reasoning for requiring that all signatures pass is that if a signature +scheme has recently become deprecated because it is known to be cryptographically +flawed then Signers will use a second (unbroken) signature scheme. However, such +a Signer may still provide the other signature for the benefit of Verifiers +that have yet to upgrade -- reasoning perhaps that attacks are too expensive +to be a very significant security issue. A Verifier that determines that +one signature passes whilst the other fails may well be in a position to +prevent an attack. + +Possible errors: + + FAIL: DKIM2-Signature i= public key incorrect signature + +## Validating Body and Header hashes + +Verifying a hash value requires a Verifier to repeat the hash calculation +performed by the Signer as set out in {{computing-body-hash}} +and {{computing-body-hash}}. The values can then be directly compared. + +Since there may be more than one hash algorithm given the human-readable +error message SHOULD indicate which algorithm's result failed to match. + +Possible errors: + + FAIL: Message Instance m= header hash mismatch + FAIL: Message Instance m= body hash mismatch + +# Delivery Status Notifications in the DKIM2 ecosystem {#bounce} + +In the DKIM2 ecosystem, when a message cannot be delivered then +this is reported to the sending machine by means of an {{RFC5321}} +return code or, if the SMTP session has completed, by generating +a Delivery Status Notification (DSN, as defined in {{RFC3461}}. + +A DSN MUST be addressed to the MTA that sent the message. This +prevents "backscatter" by passing failures back along the chain +of MTAs that were in involved in passing the message forwards. This +is achieved by using the mf= tag from the highest numbered +DKIM2-Signature field. If this field is null ("mf=<>") then a DSN +MUST NOT be sent. + +*/ diff --git a/stevea/verify_errors.go b/stevea/verify_errors.go new file mode 100644 index 0000000..ff9ab0c --- /dev/null +++ b/stevea/verify_errors.go @@ -0,0 +1,388 @@ +// Code generated by validationerrors. DO NOT EDIT. + +package dkim2 + +import "fmt" + +// FAIL: Message Instance m= body hash mismatch +type ErrFailMessageInstanceBodyHashValueMismatch struct { + MInstance int + Value string + Detail string +} + +func (e ErrFailMessageInstanceBodyHashValueMismatch) Error() string { + + s := fmt.Sprintf("FAIL: Message Instance m=%d body hash %s mismatch", e.MInstance, e.Value) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrFailMessageInstanceBodyHashValueMismatch) State() VerificationState { + return StateFail +} + +// FAIL: Message Instance m= header hash mismatch +type ErrFailMessageInstanceHeaderHashValueMismatch struct { + MInstance int + Value string + Detail string +} + +func (e ErrFailMessageInstanceHeaderHashValueMismatch) Error() string { + + s := fmt.Sprintf("FAIL: Message Instance m=%d header hash %s mismatch", e.MInstance, e.Value) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrFailMessageInstanceHeaderHashValueMismatch) State() VerificationState { + return StateFail +} + +// FAIL: DKIM2-Signature i= public key incorrect signature +type ErrFailSignaturePublicKeyValueIncorrectSignature struct { + Sequence int + Value string + Detail string + Err error +} + +func (e ErrFailSignaturePublicKeyValueIncorrectSignature) Error() string { + + s := fmt.Sprintf("FAIL: DKIM2-Signature i=%d public key %s incorrect signature", e.Sequence, e.Value) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrFailSignaturePublicKeyValueIncorrectSignature) State() VerificationState { + return StateFail +} + +func (e ErrFailSignaturePublicKeyValueIncorrectSignature) Unwrap() error { + return e.Err +} + +// FAIL: DKIM2-Signature i= validation failed +type ErrFailSignatureValidationFailed struct { + Sequence int + Detail string +} + +func (e ErrFailSignatureValidationFailed) Error() string { + + s := fmt.Sprintf("FAIL: DKIM2-Signature i=%d validation failed", e.Sequence) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrFailSignatureValidationFailed) State() VerificationState { + return StateFail +} + +// PERMERROR: MAIL FROM and d= do not match +type ErrMailFromAndDoNotMatch struct{} + +func (e ErrMailFromAndDoNotMatch) Error() string { + + return fmt.Sprintf("PERMERROR: MAIL FROM and d= do not match") + +} + +func (_ ErrMailFromAndDoNotMatch) State() VerificationState { + return StatePermError +} + +// PERMERROR: MAIL FROM did not match +type ErrMailFromValueDidNotMatch struct { + Value string +} + +func (e ErrMailFromValueDidNotMatch) Error() string { + + return fmt.Sprintf("PERMERROR: MAIL FROM %s did not match", e.Value) + +} + +func (_ ErrMailFromValueDidNotMatch) State() VerificationState { + return StatePermError +} + +// PERMERROR Message-Instance m= is not signed +type ErrMessageInstanceIsNotSigned struct { + MInstance int +} + +func (e ErrMessageInstanceIsNotSigned) Error() string { + + return fmt.Sprintf("PERMERROR Message-Instance m=%d is not signed", e.MInstance) + +} + +func (_ ErrMessageInstanceIsNotSigned) State() VerificationState { + return StatePermError +} + +// PERMERROR Message-Instance m= missing +type ErrMessageInstanceMissing struct { + MInstance int +} + +func (e ErrMessageInstanceMissing) Error() string { + + return fmt.Sprintf("PERMERROR Message-Instance m=%d missing", e.MInstance) + +} + +func (_ ErrMessageInstanceMissing) State() VerificationState { + return StatePermError +} + +// PERMERROR Message-Instance m= syntax error +type ErrMessageInstanceSyntaxError struct { + MInstance int + Header string + Detail string + Err error +} + +func (e ErrMessageInstanceSyntaxError) Error() string { + + s := fmt.Sprintf("PERMERROR Message-Instance m=%d syntax error", e.MInstance) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrMessageInstanceSyntaxError) State() VerificationState { + return StatePermError +} + +func (e ErrMessageInstanceSyntaxError) Unwrap() error { + return e.Err +} + +// PERMERROR Message-Instance m= tag= missing +type ErrMessageInstanceTagMissing struct { + MInstance int + Tag string + Header string +} + +func (e ErrMessageInstanceTagMissing) Error() string { + + return fmt.Sprintf("PERMERROR Message-Instance m=%d tag=%s missing", e.MInstance, e.Tag) + +} + +func (_ ErrMessageInstanceTagMissing) State() VerificationState { + return StatePermError +} + +// PERMERROR: RCPT TO did not match +type ErrRcptToValueDidNotMatch struct { + Value string +} + +func (e ErrRcptToValueDidNotMatch) Error() string { + + return fmt.Sprintf("PERMERROR: RCPT TO %s did not match", e.Value) + +} + +func (_ ErrRcptToValueDidNotMatch) State() VerificationState { + return StatePermError +} + +// PERMERROR DKIM2-Signature i= signature expired +type ErrSignatureExpired struct { + Sequence int +} + +func (e ErrSignatureExpired) Error() string { + + return fmt.Sprintf("PERMERROR DKIM2-Signature i=%d signature expired", e.Sequence) + +} + +func (_ ErrSignatureExpired) State() VerificationState { + return StatePermError +} + +// PERMERROR DKIM2-Signature i= missing +type ErrSignatureMissing struct { + Sequence int +} + +func (e ErrSignatureMissing) Error() string { + + return fmt.Sprintf("PERMERROR DKIM2-Signature i=%d missing", e.Sequence) + +} + +func (_ ErrSignatureMissing) State() VerificationState { + return StatePermError +} + +// PERMERROR: DKIM2-Signature i= public key algorithm mismatch +type ErrSignaturePublicKeyValueAlgorithmMismatch struct { + Sequence int + Value string +} + +func (e ErrSignaturePublicKeyValueAlgorithmMismatch) Error() string { + + return fmt.Sprintf("PERMERROR: DKIM2-Signature i=%d public key %s algorithm mismatch", e.Sequence, e.Value) + +} + +func (_ ErrSignaturePublicKeyValueAlgorithmMismatch) State() VerificationState { + return StatePermError +} + +// PERMERROR: DKIM2-Signature i= public key does not exist +type ErrSignaturePublicKeyValueDoesNotExist struct { + Sequence int + Value string +} + +func (e ErrSignaturePublicKeyValueDoesNotExist) Error() string { + + return fmt.Sprintf("PERMERROR: DKIM2-Signature i=%d public key %s does not exist", e.Sequence, e.Value) + +} + +func (_ ErrSignaturePublicKeyValueDoesNotExist) State() VerificationState { + return StatePermError +} + +// PERMERROR: DKIM2-Signature i= public key has a syntax error +type ErrSignaturePublicKeyValueHasASyntaxError struct { + Sequence int + Value string + Err error + Detail string +} + +func (e ErrSignaturePublicKeyValueHasASyntaxError) Error() string { + + s := fmt.Sprintf("PERMERROR: DKIM2-Signature i=%d public key %s has a syntax error", e.Sequence, e.Value) + if len(e.Detail) == 0 { + return s + } + return s + " [" + e.Detail + "]" + +} + +func (_ ErrSignaturePublicKeyValueHasASyntaxError) State() VerificationState { + return StatePermError +} + +func (e ErrSignaturePublicKeyValueHasASyntaxError) Unwrap() error { + return e.Err +} + +// PERMERROR: DKIM2-Signature i= public key has been revoked +type ErrSignaturePublicKeyValueHasBeenRevoked struct { + Sequence int + Value string +} + +func (e ErrSignaturePublicKeyValueHasBeenRevoked) Error() string { + + return fmt.Sprintf("PERMERROR: DKIM2-Signature i=%d public key %s has been revoked", e.Sequence, e.Value) + +} + +func (_ ErrSignaturePublicKeyValueHasBeenRevoked) State() VerificationState { + return StatePermError +} + +// PERMERROR: DKIM2-Signature i= public key has multiple records +type ErrSignaturePublicKeyValueHasMultipleRecords struct { + Sequence int + Value string +} + +func (e ErrSignaturePublicKeyValueHasMultipleRecords) Error() string { + + return fmt.Sprintf("PERMERROR: DKIM2-Signature i=%d public key %s has multiple records", e.Sequence, e.Value) + +} + +func (_ ErrSignaturePublicKeyValueHasMultipleRecords) State() VerificationState { + return StatePermError +} + +// PERMERROR DKIM2-Signature i= syntax error +type ErrSignatureSyntaxError struct { + Sequence int + Header string + Err error +} + +func (e ErrSignatureSyntaxError) Error() string { + + return fmt.Sprintf("PERMERROR DKIM2-Signature i=%d syntax error", e.Sequence) + +} + +func (_ ErrSignatureSyntaxError) State() VerificationState { + return StatePermError +} + +func (e ErrSignatureSyntaxError) Unwrap() error { + return e.Err +} + +// PERMERROR DKIM2-Signature i= tag= missing +type ErrSignatureTagMissing struct { + Sequence int + Tag string + Header string +} + +func (e ErrSignatureTagMissing) Error() string { + + return fmt.Sprintf("PERMERROR DKIM2-Signature i=%d tag=%s missing", e.Sequence, e.Tag) + +} + +func (_ ErrSignatureTagMissing) State() VerificationState { + return StatePermError +} + +// TEMPERROR: DKIM2-Signature i= public key could not be fetched +type ErrTempSignaturePublicKeyValueCouldNotBeFetched struct { + Sequence int + Value string + Err error +} + +func (e ErrTempSignaturePublicKeyValueCouldNotBeFetched) Error() string { + + return fmt.Sprintf("TEMPERROR: DKIM2-Signature i=%d public key %s could not be fetched", e.Sequence, e.Value) + +} + +func (_ ErrTempSignaturePublicKeyValueCouldNotBeFetched) State() VerificationState { + return StateTempError +} + +func (e ErrTempSignaturePublicKeyValueCouldNotBeFetched) Unwrap() error { + return e.Err +}