1package dkim
2
3import (
4 "bytes"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "strings"
9
10 "github.com/mjl-/mox/dns"
11 "github.com/mjl-/mox/message"
12 "github.com/mjl-/mox/smtp"
13)
14
15// Sig is a DKIM-Signature header.
16//
17// String values must be compared case insensitively.
18type Sig struct {
19 // Required fields.
20 Version int // Version, 1. Field "v". Always the first field.
21 AlgorithmSign string // "rsa" or "ed25519". Field "a".
22 AlgorithmHash string // "sha256" or the deprecated "sha1" (deprecated). Field "a".
23 Signature []byte // Field "b".
24 BodyHash []byte // Field "bh".
25 Domain dns.Domain // Field "d".
26 SignedHeaders []string // Duplicates are meaningful. Field "h".
27 Selector dns.Domain // Selector, for looking DNS TXT record at <s>._domainkey.<domain>. Field "s".
28
29 // Optional fields.
30 // Canonicalization is the transformation of header and/or body before hashing. The
31 // value is in original case, but must be compared case-insensitively. Normally two
32 // slash-separated values: header canonicalization and body canonicalization. But
33 // the "simple" means "simple/simple" and "relaxed" means "relaxed/simple". Field
34 // "c".
35 Canonicalization string
36 Length int64 // Body length to verify, default -1 for whole body. Field "l".
37 Identity *Identity // AUID (agent/user id). If nil and an identity is needed, should be treated as an Identity without localpart and Domain from d= field. Field "i".
38 QueryMethods []string // For public key, currently known value is "dns/txt" (should be compared case-insensitively). If empty, dns/txt must be assumed. Field "q".
39 SignTime int64 // Unix epoch. -1 if unset. Field "t".
40 ExpireTime int64 // Unix epoch. -1 if unset. Field "x".
41 CopiedHeaders []string // Copied header fields. Field "z".
42}
43
44// Identity is used for the optional i= field in a DKIM-Signature header. It uses
45// the syntax of an email address, but does not necessarily represent one.
46type Identity struct {
47 Localpart *smtp.Localpart // Optional.
48 Domain dns.Domain
49}
50
51// String returns a value for use in the i= DKIM-Signature field.
52func (i Identity) String() string {
53 s := "@" + i.Domain.ASCII
54 // We need localpart as pointer to indicate it is missing because localparts can be
55 // "" which we store (decoded) as empty string and we need to differentiate.
56 if i.Localpart != nil {
57 s = i.Localpart.String() + s
58 }
59 return s
60}
61
62func newSigWithDefaults() *Sig {
63 return &Sig{
64 Canonicalization: "simple/simple",
65 Length: -1,
66 SignTime: -1,
67 ExpireTime: -1,
68 }
69}
70
71// Algorithm returns an algorithm string for use in the "a" field. E.g.
72// "ed25519-sha256".
73func (s Sig) Algorithm() string {
74 return s.AlgorithmSign + "-" + s.AlgorithmHash
75}
76
77// Header returns the DKIM-Signature header in string form, to be prepended to a
78// message, including DKIM-Signature field name and trailing \r\n.
79func (s *Sig) Header() (string, error) {
80 // ../rfc/6376:1021
81 // todo: make a higher-level writer that accepts pairs, and only folds to next line when needed.
82 w := &message.HeaderWriter{}
83 w.Addf("", "DKIM-Signature: v=%d;", s.Version)
84 // Domain names must always be in ASCII. ../rfc/6376:1115 ../rfc/6376:1187 ../rfc/6376:1303
85 w.Addf(" ", "d=%s;", s.Domain.ASCII)
86 w.Addf(" ", "s=%s;", s.Selector.ASCII)
87 if s.Identity != nil {
88 w.Addf(" ", "i=%s;", s.Identity.String()) // todo: Is utf-8 ok here?
89 }
90 w.Addf(" ", "a=%s;", s.Algorithm())
91
92 if s.Canonicalization != "" && !strings.EqualFold(s.Canonicalization, "simple") && !strings.EqualFold(s.Canonicalization, "simple/simple") {
93 w.Addf(" ", "c=%s;", s.Canonicalization)
94 }
95 if s.Length >= 0 {
96 w.Addf(" ", "l=%d;", s.Length)
97 }
98 if len(s.QueryMethods) > 0 && !(len(s.QueryMethods) == 1 && strings.EqualFold(s.QueryMethods[0], "dns/txt")) {
99 w.Addf(" ", "q=%s;", strings.Join(s.QueryMethods, ":"))
100 }
101 if s.SignTime >= 0 {
102 w.Addf(" ", "t=%d;", s.SignTime)
103 }
104 if s.ExpireTime >= 0 {
105 w.Addf(" ", "x=%d;", s.ExpireTime)
106 }
107
108 if len(s.SignedHeaders) > 0 {
109 for i, v := range s.SignedHeaders {
110 sep := ""
111 if i == 0 {
112 v = "h=" + v
113 sep = " "
114 }
115 if i < len(s.SignedHeaders)-1 {
116 v += ":"
117 } else if i == len(s.SignedHeaders)-1 {
118 v += ";"
119 }
120 w.Addf(sep, v)
121 }
122 }
123 if len(s.CopiedHeaders) > 0 {
124 // todo: wrap long headers? we can at least add FWS before the :
125 for i, v := range s.CopiedHeaders {
126 t := strings.SplitN(v, ":", 2)
127 if len(t) == 2 {
128 v = t[0] + ":" + packQpHdrValue(t[1])
129 } else {
130 return "", fmt.Errorf("invalid header in copied headers (z=): %q", v)
131 }
132 sep := ""
133 if i == 0 {
134 v = "z=" + v
135 sep = " "
136 }
137 if i < len(s.CopiedHeaders)-1 {
138 v += "|"
139 } else if i == len(s.CopiedHeaders)-1 {
140 v += ";"
141 }
142 w.Addf(sep, v)
143 }
144 }
145
146 w.Addf(" ", "bh=%s;", base64.StdEncoding.EncodeToString(s.BodyHash))
147
148 w.Addf(" ", "b=")
149 if len(s.Signature) > 0 {
150 w.AddWrap([]byte(base64.StdEncoding.EncodeToString(s.Signature)))
151 }
152 w.Add("\r\n")
153 return w.String(), nil
154}
155
156// Like quoted printable, but with "|" encoded as well.
157// We also encode ":" because it is used as separator in DKIM headers which can
158// cause trouble for "q", even though it is listed in dkim-safe-char,
159// ../rfc/6376:497.
160func packQpHdrValue(s string) string {
161 // ../rfc/6376:474
162 const hex = "0123456789ABCDEF"
163 var r string
164 for _, b := range []byte(s) {
165 if b > ' ' && b < 0x7f && b != ';' && b != '=' && b != '|' && b != ':' {
166 r += string(b)
167 } else {
168 r += "=" + string(hex[b>>4]) + string(hex[(b>>0)&0xf])
169 }
170 }
171 return r
172}
173
174var (
175 errSigHeader = errors.New("not DKIM-Signature header")
176 errSigDuplicateTag = errors.New("duplicate tag")
177 errSigMissingCRLF = errors.New("missing crlf at end")
178 errSigExpired = errors.New("signature timestamp (t=) must be before signature expiration (x=)")
179 errSigIdentityDomain = errors.New("identity domain (i=) not under domain (d=)")
180 errSigMissingTag = errors.New("missing required tag")
181 errSigUnknownVersion = errors.New("unknown version")
182 errSigBodyHash = errors.New("bad body hash size given algorithm")
183)
184
185// parseSignatures returns the parsed form of a DKIM-Signature header.
186//
187// buf must end in crlf, as it should have occurred in the mail message.
188//
189// The dkim signature with signature left empty ("b=") and without trailing
190// crlf is returned, for use in verification.
191func parseSignature(buf []byte, smtputf8 bool) (sig *Sig, verifySig []byte, err error) {
192 defer func() {
193 if x := recover(); x == nil {
194 return
195 } else if xerr, ok := x.(error); ok {
196 sig = nil
197 verifySig = nil
198 err = xerr
199 } else {
200 panic(x)
201 }
202 }()
203
204 xerrorf := func(format string, args ...any) {
205 panic(fmt.Errorf(format, args...))
206 }
207
208 if !bytes.HasSuffix(buf, []byte("\r\n")) {
209 xerrorf("%w", errSigMissingCRLF)
210 }
211 buf = buf[:len(buf)-2]
212
213 ds := newSigWithDefaults()
214 seen := map[string]struct{}{}
215 p := parser{s: string(buf), smtputf8: smtputf8}
216 name := p.xhdrName(false)
217 if !strings.EqualFold(name, "DKIM-Signature") {
218 xerrorf("%w", errSigHeader)
219 }
220 p.wsp()
221 p.xtake(":")
222 p.wsp()
223 // ../rfc/6376:655
224 // ../rfc/6376:656 ../rfc/6376-eid5070
225 // ../rfc/6376:658 ../rfc/6376-eid5070
226 for {
227 p.fws()
228 k := p.xtagName()
229 p.fws()
230 p.xtake("=")
231 // Special case for "b", see below.
232 if k != "b" {
233 p.fws()
234 }
235 // Keys are case-sensitive: ../rfc/6376:679
236 if _, ok := seen[k]; ok {
237 // Duplicates not allowed: ../rfc/6376:683
238 xerrorf("%w: %q", errSigDuplicateTag, k)
239 break
240 }
241 seen[k] = struct{}{}
242
243 // ../rfc/6376:1021
244 switch k {
245 case "v":
246 // ../rfc/6376:1025
247 ds.Version = int(p.xnumber(10))
248 if ds.Version != 1 {
249 xerrorf("%w: version %d", errSigUnknownVersion, ds.Version)
250 }
251 case "a":
252 // ../rfc/6376:1038
253 ds.AlgorithmSign, ds.AlgorithmHash = p.xalgorithm()
254 case "b":
255 // ../rfc/6376:1054
256 // To calculate the hash, we have to feed the DKIM-Signature header to the hash
257 // function, but with the value for "b=" (the signature) left out. The parser
258 // tracks all data that is read, except when drop is true.
259 // ../rfc/6376:997
260 // Surrounding whitespace must be cleared as well. ../rfc/6376:1659
261 // Note: The RFC says "surrounding" whitespace, but whitespace is only allowed
262 // before the value as part of the ABNF production for "b". Presumably the
263 // intention is to ignore the trailing "[FWS]" for the tag-spec production,
264 // ../rfc/6376:656
265 // Another indication is the term "value portion", ../rfc/6376:1667. It appears to
266 // mean everything after the "b=" part, instead of the actual value (either encoded
267 // or decoded).
268 p.drop = true
269 p.fws()
270 ds.Signature = p.xbase64()
271 p.fws()
272 p.drop = false
273 case "bh":
274 // ../rfc/6376:1076
275 ds.BodyHash = p.xbase64()
276 case "c":
277 // ../rfc/6376:1088
278 ds.Canonicalization = p.xcanonical()
279 // ../rfc/6376:810
280 case "d":
281 // ../rfc/6376:1105
282 ds.Domain = p.xdomain()
283 case "h":
284 // ../rfc/6376:1134
285 ds.SignedHeaders = p.xsignedHeaderFields()
286 case "i":
287 // ../rfc/6376:1171
288 id := p.xauid()
289 ds.Identity = &id
290 case "l":
291 // ../rfc/6376:1244
292 ds.Length = p.xbodyLength()
293 case "q":
294 // ../rfc/6376:1268
295 ds.QueryMethods = p.xqueryMethods()
296 case "s":
297 // ../rfc/6376:1300
298 ds.Selector = p.xselector()
299 case "t":
300 // ../rfc/6376:1310
301 ds.SignTime = p.xtimestamp()
302 case "x":
303 // ../rfc/6376:1327
304 ds.ExpireTime = p.xtimestamp()
305 case "z":
306 // ../rfc/6376:1361
307 ds.CopiedHeaders = p.xcopiedHeaderFields()
308 default:
309 // We must ignore unknown fields. ../rfc/6376:692 ../rfc/6376:1022
310 p.xchar() // ../rfc/6376-eid5070
311 for !p.empty() && !p.hasPrefix(";") {
312 p.xchar()
313 }
314 }
315 p.fws()
316
317 if p.empty() {
318 break
319 }
320 p.xtake(";")
321 if p.empty() {
322 break
323 }
324 }
325
326 // ../rfc/6376:2532
327 required := []string{"v", "a", "b", "bh", "d", "h", "s"}
328 for _, req := range required {
329 if _, ok := seen[req]; !ok {
330 xerrorf("%w: %q", errSigMissingTag, req)
331 }
332 }
333
334 if strings.EqualFold(ds.AlgorithmHash, "sha1") && len(ds.BodyHash) != 20 {
335 xerrorf("%w: got %d bytes, must be 20 for sha1", errSigBodyHash, len(ds.BodyHash))
336 } else if strings.EqualFold(ds.AlgorithmHash, "sha256") && len(ds.BodyHash) != 32 {
337 xerrorf("%w: got %d bytes, must be 32 for sha256", errSigBodyHash, len(ds.BodyHash))
338 }
339
340 // ../rfc/6376:1337
341 if ds.SignTime >= 0 && ds.ExpireTime >= 0 && ds.SignTime >= ds.ExpireTime {
342 xerrorf("%w", errSigExpired)
343 }
344
345 // Default identity is "@" plus domain. We don't set this value because we want to
346 // keep the distinction between absent value.
347 // ../rfc/6376:1172 ../rfc/6376:2537 ../rfc/6376:2541
348 if ds.Identity != nil && ds.Identity.Domain.ASCII != ds.Domain.ASCII && !strings.HasSuffix(ds.Identity.Domain.ASCII, "."+ds.Domain.ASCII) {
349 xerrorf("%w: identity domain %q not under domain %q", errSigIdentityDomain, ds.Identity.Domain.ASCII, ds.Domain.ASCII)
350 }
351
352 return ds, []byte(p.tracked), nil
353}
354