internal/mailreply/mailreply.go

v1.43.1
gitbay/internal/mailreply/mailreply.go history · blame · raw

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}