1package dkim
2
3import (
4 "encoding/base64"
5 "fmt"
6 "strconv"
7 "strings"
8
9 "golang.org/x/text/unicode/norm"
10
11 "github.com/mjl-/mox/dns"
12 "github.com/mjl-/mox/smtp"
13)
14
15// Pedantic enables stricter parsing.
16var Pedantic bool
17
18type parseErr string
19
20func (e parseErr) Error() string {
21 return string(e)
22}
23
24var _ error = parseErr("")
25
26type parser struct {
27 s string
28 o int // Offset into s.
29 tracked string // All data consumed, except when "drop" is true. To be set by caller when parsing the value for "b=".
30 drop bool
31 smtputf8 bool // If set, allow characters > 0x7f.
32}
33
34func (p *parser) xerrorf(format string, args ...any) {
35 msg := fmt.Sprintf(format, args...)
36 if p.o < len(p.s) {
37 msg = fmt.Sprintf("%s (leftover %q)", msg, p.s[p.o:])
38 }
39 panic(parseErr(msg))
40}
41
42func (p *parser) track(s string) {
43 if !p.drop {
44 p.tracked += s
45 }
46}
47
48func (p *parser) hasPrefix(s string) bool {
49 return strings.HasPrefix(p.s[p.o:], s)
50}
51
52func (p *parser) xtaken(n int) string {
53 r := p.s[p.o : p.o+n]
54 p.o += n
55 p.track(r)
56 return r
57}
58
59func (p *parser) xtakefn(ignoreFWS bool, fn func(c rune, i int) bool) string {
60 var r string
61 for i, c := range p.s[p.o:] {
62 if !fn(c, i) {
63 switch c {
64 case ' ', '\t', '\r', '\n':
65 continue
66 }
67 p.xtaken(i)
68 return r
69 }
70 r += string(c)
71 }
72 p.xtaken(len(p.s) - p.o)
73 return r
74}
75
76func (p *parser) empty() bool {
77 return p.o >= len(p.s)
78}
79
80func (p *parser) xnonempty() {
81 if p.o >= len(p.s) {
82 p.xerrorf("expected at least 1 more char")
83 }
84}
85
86func (p *parser) xtakefn1(ignoreFWS bool, fn func(c rune, i int) bool) string {
87 var r string
88 p.xnonempty()
89 for i, c := range p.s[p.o:] {
90 if !fn(c, i) {
91 switch c {
92 case ' ', '\t', '\r', '\n':
93 continue
94 }
95 if i == 0 {
96 p.xerrorf("expected at least 1 char")
97 }
98 p.xtaken(i)
99 return r
100 }
101 r += string(c)
102 }
103 return p.xtaken(len(p.s) - p.o)
104}
105
106func (p *parser) wsp() {
107 p.xtakefn(false, func(c rune, i int) bool {
108 return c == ' ' || c == '\t'
109 })
110}
111
112func (p *parser) fws() {
113 p.wsp()
114 if p.hasPrefix("\r\n ") || p.hasPrefix("\r\n\t") {
115 p.xtaken(3)
116 p.wsp()
117 }
118}
119
120// peekfws returns whether remaining text starts with s, optionally prefix with fws.
121func (p *parser) peekfws(s string) bool {
122 o := p.o
123 p.fws()
124 r := p.hasPrefix(s)
125 p.o = o
126 return r
127}
128
129func (p *parser) xtake(s string) string {
130 if !strings.HasPrefix(p.s[p.o:], s) {
131 p.xerrorf("expected %q", s)
132 }
133 return p.xtaken(len(s))
134}
135
136func (p *parser) take(s string) bool {
137 if strings.HasPrefix(p.s[p.o:], s) {
138 p.o += len(s)
139 p.track(s)
140 return true
141 }
142 return false
143}
144
145// ../rfc/6376:657
146func (p *parser) xtagName() string {
147 return p.xtakefn1(false, func(c rune, i int) bool {
148 return isalpha(c) || i > 0 && (isdigit(c) || c == '_')
149 })
150}
151
152func (p *parser) xalgorithm() (string, string) {
153 // ../rfc/6376:1046
154 xtagx := func(c rune, i int) bool {
155 return isalpha(c) || i > 0 && isdigit(c)
156 }
157 algk := p.xtakefn1(false, xtagx)
158 p.xtake("-")
159 algv := p.xtakefn1(false, xtagx)
160 return algk, algv
161}
162
163// fws in value is ignored. empty/no base64 characters is valid.
164// ../rfc/6376:1021
165// ../rfc/6376:1076
166func (p *parser) xbase64() []byte {
167 s := ""
168 p.xtakefn(false, func(c rune, i int) bool {
169 if isalphadigit(c) || c == '+' || c == '/' || c == '=' {
170 s += string(c)
171 return true
172 }
173 if c == ' ' || c == '\t' {
174 return true
175 }
176 rem := p.s[p.o+i:]
177 if strings.HasPrefix(rem, "\r\n ") || strings.HasPrefix(rem, "\r\n\t") {
178 return true
179 }
180 if (strings.HasPrefix(rem, "\n ") || strings.HasPrefix(rem, "\n\t")) && p.o+i-1 > 0 && p.s[p.o+i-1] == '\r' {
181 return true
182 }
183 return false
184 })
185 buf, err := base64.StdEncoding.DecodeString(s)
186 if err != nil {
187 p.xerrorf("decoding base64: %v", err)
188 }
189 return buf
190}
191
192// parses canonicalization in original case.
193func (p *parser) xcanonical() string {
194 // ../rfc/6376:1100
195 s := p.xhyphenatedWord()
196 if p.take("/") {
197 return s + "/" + p.xhyphenatedWord()
198 }
199 return s
200}
201
202func (p *parser) xdomainselector(isselector bool) dns.Domain {
203 subdomain := func(c rune, i int) bool {
204 // domain names must always be a-labels, ../rfc/6376:1115 ../rfc/6376:1187 ../rfc/6376:1303
205 // dkim selectors with underscores happen in the wild, accept them when not in
206 // pedantic mode. ../rfc/6376:581 ../rfc/5321:2303
207 return isalphadigit(c) || (i > 0 && (c == '-' || isselector && !Pedantic && c == '_') && p.o+1 < len(p.s))
208 }
209 var s strings.Builder
210 s.WriteString(p.xtakefn1(false, subdomain))
211 for p.hasPrefix(".") {
212 s.WriteString(p.xtake(".") + p.xtakefn1(false, subdomain))
213 }
214 if isselector {
215 // Not to be interpreted as IDNA.
216 return dns.Domain{ASCII: strings.ToLower(s.String())}
217 }
218 d, err := dns.ParseDomain(s.String())
219 if err != nil {
220 p.xerrorf("parsing domain %q: %s", s.String(), err)
221 }
222 return d
223}
224
225func (p *parser) xdomain() dns.Domain {
226 return p.xdomainselector(false)
227}
228
229func (p *parser) xselector() dns.Domain {
230 return p.xdomainselector(true)
231}
232
233func (p *parser) xhdrName(ignoreFWS bool) string {
234 // ../rfc/6376:473
235 // ../rfc/5322:1689
236 // BNF for hdr-name (field-name) allows ";", but DKIM disallows unencoded semicolons. ../rfc/6376:643
237 // ignoreFWS is needed for "z=", which can have FWS anywhere. ../rfc/6376:1372
238 return p.xtakefn1(ignoreFWS, func(c rune, i int) bool {
239 return c > ' ' && c < 0x7f && c != ':' && c != ';'
240 })
241}
242
243func (p *parser) xsignedHeaderFields() []string {
244 // ../rfc/6376:1157
245 l := []string{p.xhdrName(false)}
246 for p.peekfws(":") {
247 p.fws()
248 p.xtake(":")
249 p.fws()
250 l = append(l, p.xhdrName(false))
251 }
252 return l
253}
254
255func (p *parser) xauid() Identity {
256 // ../rfc/6376:1192
257 // Localpart is optional.
258 if p.take("@") {
259 return Identity{Domain: p.xdomain()}
260 }
261 lp := p.xlocalpart()
262 p.xtake("@")
263 dom := p.xdomain()
264 return Identity{&lp, dom}
265}
266
267// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
268func (p *parser) xlocalpart() smtp.Localpart {
269 // ../rfc/6376:434
270 // ../rfc/5321:2316
271 var s string
272 if p.hasPrefix(`"`) {
273 s = p.xquotedString()
274 } else {
275 s = p.xatom()
276 for p.take(".") {
277 s += "." + p.xatom()
278 }
279 }
280 // In the wild, some services use large localparts for generated (bounce) addresses.
281 if Pedantic && len(s) > 64 || len(s) > 128 {
282 // ../rfc/5321:3486
283 p.xerrorf("localpart longer than 64 octets")
284 }
285 return smtp.Localpart(norm.NFC.String(s))
286}
287
288func (p *parser) xquotedString() string {
289 p.xtake(`"`)
290 var s string
291 var esc bool
292 for {
293 c := p.xchar()
294 if esc {
295 if c >= ' ' && c < 0x7f {
296 s += string(c)
297 esc = false
298 continue
299 }
300 p.xerrorf("invalid localpart, bad escaped char %c", c)
301 }
302 if c == '\\' {
303 esc = true
304 continue
305 }
306 if c == '"' {
307 return s
308 }
309 if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || (c > 0x7f && p.smtputf8) {
310 s += string(c)
311 continue
312 }
313 p.xerrorf("invalid localpart, invalid character %c", c)
314 }
315}
316
317func (p *parser) xchar() rune {
318 // We are careful to track invalid utf-8 properly.
319 if p.empty() {
320 p.xerrorf("need another character")
321 }
322 var r rune
323 var o int
324 for i, c := range p.s[p.o:] {
325 if i > 0 {
326 o = i
327 break
328 }
329 r = c
330 }
331 if o == 0 {
332 p.track(p.s[p.o:])
333 p.o = len(p.s)
334 } else {
335 p.track(p.s[p.o : p.o+o])
336 p.o += o
337 }
338 return r
339}
340
341func (p *parser) xatom() string {
342 return p.xtakefn1(false, func(c rune, i int) bool {
343 switch c {
344 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
345 return true
346 }
347 return isalphadigit(c) || (c > 0x7f && p.smtputf8)
348 })
349}
350
351func (p *parser) xbodyLength() int64 {
352 // ../rfc/6376:1265
353 return p.xnumber(76)
354}
355
356func (p *parser) xnumber(maxdigits int) int64 {
357 o := -1
358 for i, c := range p.s[p.o:] {
359 if c >= '0' && c <= '9' {
360 o = i
361 } else {
362 break
363 }
364 }
365 if o == -1 {
366 p.xerrorf("expected digits")
367 }
368 if o+1 > maxdigits {
369 p.xerrorf("too many digits")
370 }
371 v, err := strconv.ParseInt(p.xtaken(o+1), 10, 64)
372 if err != nil {
373 p.xerrorf("parsing digits: %s", err)
374 }
375 return v
376}
377
378func (p *parser) xqueryMethods() []string {
379 // ../rfc/6376:1285
380 l := []string{p.xqtagmethod()}
381 for p.peekfws(":") {
382 p.fws()
383 p.xtake(":")
384 l = append(l, p.xqtagmethod())
385 }
386 return l
387}
388
389func (p *parser) xqtagmethod() string {
390 // ../rfc/6376:1295 ../rfc/6376-eid4810
391 s := p.xhyphenatedWord()
392 // ABNF production "x-sig-q-tag-args" should probably just have been
393 // "hyphenated-word". As qp-hdr-value, it will consume ":". A similar problem does
394 // not occur for "z" because it is also "|"-delimited. We work around the potential
395 // issue by parsing "dns/txt" explicitly.
396 rem := p.s[p.o:]
397 if strings.EqualFold(s, "dns") && len(rem) >= len("/txt") && strings.EqualFold(rem[:len("/txt")], "/txt") {
398 s += p.xtaken(4)
399 } else if p.take("/") {
400 s += "/" + p.xqp(true, true, false)
401 }
402 return s
403}
404
405func isalpha(c rune) bool {
406 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
407}
408
409func isdigit(c rune) bool {
410 return c >= '0' && c <= '9'
411}
412
413func isalphadigit(c rune) bool {
414 return isalpha(c) || isdigit(c)
415}
416
417// ../rfc/6376:469
418func (p *parser) xhyphenatedWord() string {
419 return p.xtakefn1(false, func(c rune, i int) bool {
420 return isalpha(c) || i > 0 && isdigit(c) || i > 0 && c == '-' && p.o+i+1 < len(p.s) && isalphadigit(rune(p.s[p.o+i+1]))
421 })
422}
423
424// ../rfc/6376:474
425func (p *parser) xqphdrvalue(ignoreFWS bool) string {
426 return p.xqp(true, false, ignoreFWS)
427}
428
429func (p *parser) xqpSection() string {
430 return p.xqp(false, false, false)
431}
432
433// dkim-quoted-printable (pipeEncoded true) or qp-section.
434//
435// It is described in terms of (lots of) modifications to MIME quoted-printable,
436// but it may be simpler to just ignore that reference.
437//
438// ignoreFWS is required for "z=", which can have FWS anywhere.
439func (p *parser) xqp(pipeEncoded, colonEncoded, ignoreFWS bool) string {
440 // ../rfc/6376:494 ../rfc/2045:1260
441
442 hex := func(c byte) rune {
443 if c >= '0' && c <= '9' {
444 return rune(c - '0')
445 }
446 return rune(10 + c - 'A')
447 }
448
449 var s strings.Builder
450 for !p.empty() {
451 p.fws()
452 if pipeEncoded && p.hasPrefix("|") {
453 break
454 }
455 if colonEncoded && p.hasPrefix(":") {
456 break
457 }
458 if p.take("=") {
459 h := p.xtakefn(ignoreFWS, func(c rune, i int) bool {
460 return i < 2 && (c >= '0' && c <= '9' || c >= 'A' && c <= 'Z')
461 })
462 if len(h) != 2 {
463 p.xerrorf("expected qp-hdr-value")
464 }
465 c := (hex(h[0]) << 4) | hex(h[1])
466 s.WriteString(string(c))
467 continue
468 }
469 x := p.xtakefn(ignoreFWS, func(c rune, i int) bool {
470 return c > ' ' && c < 0x7f && c != ';' && c != '=' && !(pipeEncoded && c == '|')
471 })
472 if x == "" {
473 break
474 }
475 s.WriteString(x)
476 }
477 return s.String()
478}
479
480func (p *parser) xtimestamp() int64 {
481 // ../rfc/6376:1325 ../rfc/6376:1358
482 return p.xnumber(12)
483}
484
485func (p *parser) xcopiedHeaderFields() []string {
486 // ../rfc/6376:1384
487 l := []string{p.xztagcopy()}
488 for p.hasPrefix("|") {
489 p.xtake("|")
490 p.fws()
491 l = append(l, p.xztagcopy())
492 }
493 return l
494}
495
496func (p *parser) xztagcopy() string {
497 // ABNF does not mention FWS (unlike for other fields), but FWS is allowed everywhere in the value...
498 // ../rfc/6376:1386 ../rfc/6376:1372
499 f := p.xhdrName(true)
500 p.fws()
501 p.xtake(":")
502 v := p.xqphdrvalue(true)
503 return f + ":" + v
504}
505