1// Package subjectpass implements a mechanism for reject an incoming message with a challenge to include a token in a next delivery attempt.
2//
3// An SMTP server can reject a message with instructions to send another
4// message, this time including a special token. The sender will receive a DSN,
5// which will include the error message with instructions. By sending the
6// message again with the token, as instructed, the SMTP server can recognize
7// the token, verify it, and accept the message.
8package subjectpass
9
10import (
11 "crypto/hmac"
12 "crypto/sha256"
13 "encoding/base64"
14 "errors"
15 "fmt"
16 "io"
17 "log/slog"
18 "strings"
19 "time"
20
21 "github.com/mjl-/mox/dns"
22 "github.com/mjl-/mox/message"
23 "github.com/mjl-/mox/mlog"
24 "github.com/mjl-/mox/smtp"
25 "github.com/mjl-/mox/stub"
26)
27
28var (
29 MetricGenerate stub.Counter = stub.CounterIgnore{}
30 MetricVerify stub.CounterVec = stub.CounterVecIgnore{}
31)
32
33var (
34 ErrMessage = errors.New("subjectpass: malformed message")
35 ErrAbsent = errors.New("subjectpass: no token found")
36 ErrFrom = errors.New("subjectpass: bad From")
37 ErrInvalid = errors.New("subjectpass: malformed token")
38 ErrVerify = errors.New("subjectpass: verification failed")
39 ErrExpired = errors.New("subjectpass: token expired")
40)
41
42var Explanation = "Your message resembles spam. If your email is legitimate, please send it again with the following added to the email message subject: "
43
44// Generate generates a token that is valid for "mailFrom", starting from "tm"
45// and signed with "key".
46//
47// The token is of the form: (pass:<signeddata>). Instructions to the sender should
48// be to include this token in the Subject header of a new message.
49func Generate(elog *slog.Logger, mailFrom smtp.Address, key []byte, tm time.Time) string {
50 log := mlog.New("subjectpass", elog)
51
52 MetricGenerate.Inc()
53 log.Debug("subjectpass generate", slog.Any("mailfrom", mailFrom))
54
55 // We discard the lower 8 bits of the time, we can do with less precision.
56 t := tm.Unix()
57 buf := []byte{
58 0 | (byte(t>>32) & 0x0f), // 4 bits version, 4 bits time
59 byte(t>>24) & 0xff,
60 byte(t>>16) & 0xff,
61 byte(t>>8) & 0xff,
62 }
63 mac := hmac.New(sha256.New, key)
64 mac.Write(buf)
65 mac.Write([]byte(mailFrom.String()))
66 h := mac.Sum(nil)[:12]
67 buf = append(buf, h...)
68 return "(pass:" + base64.RawURLEncoding.EncodeToString(buf) + ")"
69}
70
71// Verify parses "message" and checks if it includes a subjectpass token in its
72// Subject header that is still valid (within "period") and signed with "key".
73func Verify(elog *slog.Logger, r io.ReaderAt, key []byte, period time.Duration) (rerr error) {
74 log := mlog.New("subjectpass", elog)
75
76 var token string
77
78 defer func() {
79 result := "fail"
80 if rerr == nil {
81 result = "ok"
82 }
83 MetricVerify.IncLabels(result)
84
85 log.Debugx("subjectpass verify result", rerr, slog.String("token", token), slog.Duration("period", period))
86 }()
87
88 p, err := message.Parse(log.Logger, true, r)
89 if err != nil {
90 return fmt.Errorf("%w: parse message: %s", ErrMessage, err)
91 }
92 header, err := p.Header()
93 if err != nil {
94 return fmt.Errorf("%w: parse message headers: %s", ErrMessage, err)
95 }
96 subject := header.Get("Subject")
97 if subject == "" {
98 log.Info("no subject header")
99 return fmt.Errorf("%w: no subject header", ErrAbsent)
100 }
101 t := strings.SplitN(subject, "(pass:", 2)
102 if len(t) != 2 {
103 return fmt.Errorf("%w: no token in subject", ErrAbsent)
104 }
105 t = strings.SplitN(t[1], ")", 2)
106 if len(t) != 2 {
107 return fmt.Errorf("%w: no token in subject (2)", ErrAbsent)
108 }
109 token = t[0]
110
111 if len(p.Envelope.From) != 1 {
112 return fmt.Errorf("%w: need 1 from address, got %d", ErrFrom, len(p.Envelope.From))
113 }
114 from := p.Envelope.From[0]
115 d, err := dns.ParseDomain(from.Host)
116 if err != nil {
117 return fmt.Errorf("%w: from address with bad domain: %v", ErrFrom, err)
118 }
119 lp, err := smtp.ParseLocalpart(from.User)
120 if err != nil {
121 return fmt.Errorf("%w: from address with bad localpart: %v", ErrFrom, err)
122 }
123 addr := smtp.Address{Localpart: lp, Domain: d}.Pack(true)
124
125 buf, err := base64.RawURLEncoding.DecodeString(token)
126 if err != nil {
127 return fmt.Errorf("%w: parsing base64: %s", ErrInvalid, err)
128 }
129
130 if len(buf) == 0 {
131 return fmt.Errorf("%w: empty pass token", ErrInvalid)
132 }
133
134 version := buf[0] >> 4
135 if version != 0 {
136 return fmt.Errorf("%w: unknown version %d", ErrInvalid, version)
137 }
138 if len(buf) != 4+12 {
139 return fmt.Errorf("%w: bad length of pass token, %d", ErrInvalid, len(buf))
140 }
141 mac := hmac.New(sha256.New, key)
142 mac.Write(buf[:4])
143 mac.Write([]byte(addr))
144 h := mac.Sum(nil)[:12]
145 if !hmac.Equal(buf[4:], h) {
146 return ErrVerify
147 }
148
149 tsign := time.Unix(int64(buf[0]&0x0f)<<32|int64(buf[1])<<24|int64(buf[2])<<16|int64(buf[3])<<8, 0)
150 if time.Since(tsign) > period {
151 return fmt.Errorf("%w: pass token expired, signed at %s, period %s", ErrExpired, tsign, period)
152 }
153
154 return nil
155}
156