internal/mailreply/mailreply.go
171 lines · 4813 bytes
1// Package mailreply mints and verifies the token in a notification's
2// Reply-To address, reply+<token>@<domain> (#295). The token names the
3// recipient, the repository and the thread, and an expiry; an HMAC under
4// a key derived from the instance's secret key file binds them, so no
5// row is stored per message.
6package mailreply
7
8import (
9 "crypto/hmac"
10 "crypto/sha256"
11 "encoding/base32"
12 "encoding/binary"
13 "errors"
14 "fmt"
15 "strings"
16 "time"
17)
18
19// Lifetime is how long a notification's reply address is accepted.
20const Lifetime = 30 * 24 * time.Hour
21
22// Purpose is what the MAC key is derived for (seal.Keyring.Derive).
23const Purpose = "gitbay mail reply token v1"
24
25const (
26 version = 1
27 macLen = 12
28)
29
30// Target is the thread a reply posts to, and who it posts as.
31type Target struct {
32 UserID int64
33 RepoID int64
34 Kind string // "issue" or "mr"
35 Number int64
36 // Expires is set by Verify; Mint takes the expiry separately.
37 Expires time.Time
38}
39
40// Issued is when the token was minted: every token is minted to expire
41// Lifetime later. An account or repository created after it is not the
42// one the token named, but a later row that took a freed id.
43func (t Target) Issued() time.Time { return t.Expires.Add(-Lifetime) }
44
45var (
46 ErrMalformed = errors.New("malformed reply token")
47 ErrBadMAC = errors.New("reply token does not verify")
48 ErrExpired = errors.New("reply token expired")
49)
50
51// Mail systems may fold the local part to lower case, so the token is
52// lower-case base32.
53var enc = base32.StdEncoding.WithPadding(base32.NoPadding)
54
55// Mint returns the token for t, valid until expires, authenticated under
56// keys[0]. expires is the mint time plus Lifetime (Target.Issued).
57func Mint(keys [][]byte, t Target, expires time.Time) (string, error) {
58 if len(keys) == 0 {
59 return "", errors.New("no key to mint a reply token under")
60 }
61 var kind byte
62 switch t.Kind {
63 case "issue":
64 kind = 'i'
65 case "mr":
66 kind = 'm'
67 default:
68 return "", fmt.Errorf("reply token: unknown kind %q", t.Kind)
69 }
70 if t.UserID <= 0 || t.RepoID <= 0 || t.Number <= 0 {
71 return "", errors.New("reply token: ids must be positive")
72 }
73 p := []byte{version, kind}
74 p = binary.AppendUvarint(p, uint64(t.UserID))
75 p = binary.AppendUvarint(p, uint64(t.RepoID))
76 p = binary.AppendUvarint(p, uint64(t.Number))
77 p = binary.AppendUvarint(p, uint64(expires.Unix()))
78 p = append(p, mac(keys[0], p)...)
79 return strings.ToLower(enc.EncodeToString(p)), nil
80}
81
82// Verify checks token against every key and returns its target. An
83// expired token that verifies returns its target with ErrExpired, so the
84// refusal can name the account.
85func Verify(keys [][]byte, token string, now time.Time) (Target, error) {
86 up := strings.ToUpper(token)
87 raw, err := enc.DecodeString(up)
88 // Only the canonical encoding is accepted: base32 leaves spare bits
89 // in the last character, and a character past the last byte.
90 if err != nil || len(raw) < 2+4+macLen || enc.EncodeToString(raw) != up {
91 return Target{}, ErrMalformed
92 }
93 p, sum := raw[:len(raw)-macLen], raw[len(raw)-macLen:]
94 ok := false
95 for _, k := range keys {
96 if hmac.Equal(mac(k, p), sum) {
97 ok = true
98 }
99 }
100 if !ok {
101 return Target{}, ErrBadMAC
102 }
103 if p[0] != version {
104 return Target{}, ErrMalformed
105 }
106 var t Target
107 switch p[1] {
108 case 'i':
109 t.Kind = "issue"
110 case 'm':
111 t.Kind = "mr"
112 default:
113 return Target{}, ErrMalformed
114 }
115 rest := p[2:]
116 var v [4]uint64
117 for i := range v {
118 n, w := binary.Uvarint(rest)
119 if w <= 0 || n > 1<<62 {
120 return Target{}, ErrMalformed
121 }
122 v[i], rest = n, rest[w:]
123 }
124 if len(rest) != 0 {
125 return Target{}, ErrMalformed
126 }
127 t.UserID, t.RepoID, t.Number = int64(v[0]), int64(v[1]), int64(v[2])
128 t.Expires = time.Unix(int64(v[3]), 0).UTC()
129 if !now.Before(t.Expires) {
130 return t, ErrExpired
131 }
132 return t, nil
133}
134
135func mac(key, p []byte) []byte {
136 m := hmac.New(sha256.New, key)
137 m.Write(p)
138 return m.Sum(nil)[:macLen]
139}
140
141// Address puts token into base, the configured reply address:
142// reply@example.org becomes reply+<token>@example.org.
143func Address(base, token string) string {
144 local, domain, _ := strings.Cut(base, "@")
145 return local + "+" + token + "@" + domain
146}
147
148// TokenFrom returns the token in addr when addr is base with a token
149// added; the comparison ignores case.
150func TokenFrom(base, addr string) (string, bool) {
151 local, domain, ok := strings.Cut(base, "@")
152 if !ok {
153 return "", false
154 }
155 i := strings.LastIndexByte(addr, '@')
156 if i < 0 || !strings.EqualFold(addr[i+1:], domain) {
157 return "", false
158 }
159 tok, ok := cutPrefixFold(addr[:i], local+"+")
160 if !ok || tok == "" {
161 return "", false
162 }
163 return tok, true
164}
165
166func cutPrefixFold(s, prefix string) (string, bool) {
167 if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
168 return "", false
169 }
170 return s[len(prefix):], true
171}