10	"github.com/mjl-/mox/dns"
 
11	"github.com/mjl-/mox/message"
 
12	"github.com/mjl-/mox/smtp"
 
15// Sig is a DKIM-Signature header.
 
17// String values must be compared case insensitively.
 
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".
 
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
 
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".
 
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.
 
47	Localpart *smtp.Localpart // Optional.
 
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
 
62func newSigWithDefaults() *Sig {
 
64		Canonicalization: "simple/simple",
 
71// Algorithm returns an algorithm string for use in the "a" field. E.g.
 
73func (s Sig) Algorithm() string {
 
74	return s.AlgorithmSign + "-" + s.AlgorithmHash
 
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) {
 
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)
 
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?
 
90	w.Addf(" ", "a=%s;", s.Algorithm())
 
92	if s.Canonicalization != "" && !strings.EqualFold(s.Canonicalization, "simple") && !strings.EqualFold(s.Canonicalization, "simple/simple") {
 
93		w.Addf(" ", "c=%s;", s.Canonicalization)
 
96		w.Addf(" ", "l=%d;", s.Length)
 
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, ":"))
 
102		w.Addf(" ", "t=%d;", s.SignTime)
 
104	if s.ExpireTime >= 0 {
 
105		w.Addf(" ", "x=%d;", s.ExpireTime)
 
108	if len(s.SignedHeaders) > 0 {
 
109		for i, v := range s.SignedHeaders {
 
115			if i < len(s.SignedHeaders)-1 {
 
117			} else if i == len(s.SignedHeaders)-1 {
 
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)
 
128				v = t[0] + ":" + packQpHdrValue(t[1])
 
130				return "", fmt.Errorf("invalid header in copied headers (z=): %q", v)
 
137			if i < len(s.CopiedHeaders)-1 {
 
139			} else if i == len(s.CopiedHeaders)-1 {
 
146	w.Addf(" ", "bh=%s;", base64.StdEncoding.EncodeToString(s.BodyHash))
 
149	if len(s.Signature) > 0 {
 
150		w.AddWrap([]byte(base64.StdEncoding.EncodeToString(s.Signature)), false)
 
153	return w.String(), nil
 
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,
 
160func packQpHdrValue(s string) string {
 
162	const hex = "0123456789ABCDEF"
 
164	for _, b := range []byte(s) {
 
165		if b > ' ' && b < 0x7f && b != ';' && b != '=' && b != '|' && b != ':' {
 
168			r += "=" + string(hex[b>>4]) + string(hex[(b>>0)&0xf])
 
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")
 
185// parseSignatures returns the parsed form of a DKIM-Signature header.
 
187// buf must end in crlf, as it should have occurred in the mail message.
 
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) {
 
193		if x := recover(); x == nil {
 
195		} else if xerr, ok := x.(error); ok {
 
204	xerrorf := func(format string, args ...any) {
 
205		panic(fmt.Errorf(format, args...))
 
208	if !bytes.HasSuffix(buf, []byte("\r\n")) {
 
209		xerrorf("%w", errSigMissingCRLF)
 
211	buf = buf[:len(buf)-2]
 
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)
 
231		// Special case for "b", see below.
 
236		if _, ok := seen[k]; ok {
 
238			xerrorf("%w: %q", errSigDuplicateTag, k)
 
247			ds.Version = int(p.xnumber(10))
 
249				xerrorf("%w: version %d", errSigUnknownVersion, ds.Version)
 
253			ds.AlgorithmSign, ds.AlgorithmHash = p.xalgorithm()
 
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.
 
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,
 
266			// mean everything after the "b=" part, instead of the actual value (either encoded
 
270			ds.Signature = p.xbase64()
 
275			ds.BodyHash = p.xbase64()
 
278			ds.Canonicalization = p.xcanonical()
 
282			ds.Domain = p.xdomain()
 
285			ds.SignedHeaders = p.xsignedHeaderFields()
 
292			ds.Length = p.xbodyLength()
 
295			ds.QueryMethods = p.xqueryMethods()
 
298			ds.Selector = p.xselector()
 
301			ds.SignTime = p.xtimestamp()
 
304			ds.ExpireTime = p.xtimestamp()
 
307			ds.CopiedHeaders = p.xcopiedHeaderFields()
 
311			for !p.empty() && !p.hasPrefix(";") {
 
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)
 
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))
 
341	if ds.SignTime >= 0 && ds.ExpireTime >= 0 && ds.SignTime >= ds.ExpireTime {
 
342		xerrorf("%w", errSigExpired)
 
345	// Default identity is "@" plus domain. We don't set this value because we want to
 
346	// keep the distinction between absent value.
 
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)
 
352	return ds, []byte(p.tracked), nil