eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– The email anchor β€” sign in (or recover) with a typed 6-digit code
4// (issue #1091; design: docs/accounts-and-recovery.md Β§ Email). Email is an
5// IDENTITY anchor exactly like Sign in with Apple: a verified code answers
6// with a device link token + a fresh ceremony proof and can never decrypt
7// anything. The store only ever holds the BLIND INDEX β€” HMAC(pepper,
8// normalize(email)) β€” never an address: at sign-in the person types their
9// address, we hash and match, and the mail goes to what they typed. The
10// running process and SES see the address transiently at send time; nothing
11// keeps it.
12//
13// Codes are in-memory (the #1054 precedent): 15-minute TTL, single-use,
14// attempt-capped. The request endpoint always answers the same way β€” an
15// unknown address gets a code too (verify creates the account, the same
16// seamless front door as SiwA), which is also what makes account enumeration
17// impossible by construction.
18
19import (
20 "crypto/hmac"
21 "crypto/rand"
22 "crypto/sha256"
23 "encoding/hex"
24 "encoding/json"
25 "fmt"
26 "log"
27 "net/http"
28 "net/url"
29 "strings"
30 "sync"
31 "time"
32)
33
34const (
35 emailCodeTTL = 15 * time.Minute
36 emailCodeAttempts = 5
37 // emailCodeMinGap throttles per-address resends independently of the
38 // per-IP limiter β€” one address can't be made to spam its own inbox.
39 emailCodeMinGap = 30 * time.Second
40)
41
42// emailCodes is the in-memory single-use code store, keyed by the address's
43// blind index. One live code per address: a resend replaces it.
44type emailCodes struct {
45 mu sync.Mutex
46 codes map[string]*emailCode
47}
48
49type emailCode struct {
50 code string
51 expires time.Time
52 sent time.Time
53 attempts int
54}
55
56func newEmailCodes() *emailCodes { return &emailCodes{codes: map[string]*emailCode{}} }
57
58// issue mints a fresh code for the index unless one was sent too recently
59// (ok=false β€” the caller still answers 200; the earlier code stays valid).
60func (c *emailCodes) issue(index string, now time.Time) (string, bool) {
61 c.mu.Lock()
62 defer c.mu.Unlock()
63 if cur, ok := c.codes[index]; ok && now.Sub(cur.sent) < emailCodeMinGap {
64 return "", false
65 }
66 var b [4]byte
67 if _, err := rand.Read(b[:]); err != nil {
68 return "", false
69 }
70 n := (uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % 1000000
71 code := fmt.Sprintf("%06d", n)
72 c.codes[index] = &emailCode{code: code, expires: now.Add(emailCodeTTL), sent: now}
73 return code, true
74}
75
76// redeem checks a code for the index: single-use on success, attempt-capped
77// and expiring on failure. Comparison is constant-shaped (same work either
78// way) though the 6-digit space makes the cap, not timing, the real defence.
79func (c *emailCodes) redeem(index, code string, now time.Time) bool {
80 c.mu.Lock()
81 defer c.mu.Unlock()
82 cur, ok := c.codes[index]
83 if !ok || now.After(cur.expires) {
84 delete(c.codes, index)
85 return false
86 }
87 cur.attempts++
88 if cur.attempts > emailCodeAttempts {
89 delete(c.codes, index)
90 return false
91 }
92 if hmac.Equal([]byte(cur.code), []byte(code)) {
93 delete(c.codes, index)
94 return true
95 }
96 return false
97}
98
99func (s *Server) emailEnabled() bool { return s.mail != nil && len(s.emailPepper) > 0 }
100
101// emailSignInLink is the magic link under the code: the SAME address and code
102// the mail asks you to type, carried in the URL #fragment β€” which browsers
103// never send to any server. Clicking just runs the ordinary
104// /api/email/verify from the page, so the link adds zero server auth surface.
105// Encoding: QueryEscape with its space-"+" swapped to %20, which is exactly
106// what the page's decodeURIComponent inverts (a plus-addressed local part
107// round-trips: + β†’ %2B β†’ +).
108func emailSignInLink(origin, addr, code string) string {
109 return origin + "/#email=" + strings.ReplaceAll(url.QueryEscape(addr), "+", "%20") + "&code=" + code
110}
111
112// emailCodeBody is the sign-in mail. The code stays first and prominent β€” it
113// is the spine (works typed into any browser); the link is the same values as
114// a convenience tap.
115func emailCodeBody(origin, addr, code string) string {
116 return "Your sign-in code is " + code + "\n\n" +
117 "Or tap: " + emailSignInLink(origin, addr, code) + "\n\n" +
118 "It works for 15 minutes. If you didn't ask for it, ignore this email."
119}
120
121// emailIndex is the blind index: HMAC(pepper, lowercase(address)). Lowercase
122// is the WHOLE normalization β€” plus-addressing and provider dot-games are the
123// user's business, not ours to strip (per the design doc).
124func (s *Server) emailIndex(email string) string {
125 m := hmac.New(sha256.New, s.emailPepper)
126 m.Write([]byte(strings.ToLower(strings.TrimSpace(email))))
127 return hex.EncodeToString(m.Sum(nil))
128}
129
130// emailRequest is POST /api/email/request β€” send a sign-in code. The answer
131// is the same 200 whether or not the address is known (no enumeration), and
132// resend throttling is silent for the same reason.
133func (s *Server) emailRequest(w http.ResponseWriter, r *http.Request) {
134 if !s.emailEnabled() {
135 http.Error(w, "not found", http.StatusNotFound)
136 return
137 }
138 var req struct {
139 Email string `json:"email"`
140 }
141 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
142 http.Error(w, "bad request", http.StatusBadRequest)
143 return
144 }
145 addr := strings.TrimSpace(req.Email)
146 if addr == "" || len(addr) > 254 || !strings.Contains(addr, "@") {
147 http.Error(w, "bad request", http.StatusBadRequest)
148 return
149 }
150 if code, ok := s.emailCodes.issue(s.emailIndex(addr), time.Now()); ok {
151 // Send failures are surfaced to the caller as the same 200 β€” an
152 // attacker learns nothing, and the person retries when no mail
153 // arrives. The error is logged for the operator.
154 go func() {
155 if err := s.mail.send(addr, "Your Eleven sign-in code",
156 emailCodeBody(s.origin, addr, code)); err != nil {
157 log.Printf("email code send failed: %v", err)
158 }
159 }()
160 }
161 writeJSON(w, http.StatusOK, map[string]any{"sent": true})
162}
163
164// emailVerify is POST /api/email/verify β€” redeem a code. Mirrors siwaVerify
165// exactly: sign-in resolves-or-creates the account behind the blind index and
166// answers like finishCeremony; link:true + X-Ceremony-Proof attaches the
167// email to an existing account (one email, one account; 409 on conflict).
168func (s *Server) emailVerify(w http.ResponseWriter, r *http.Request) {
169 if !s.emailEnabled() {
170 http.Error(w, "not found", http.StatusNotFound)
171 return
172 }
173 var req struct {
174 Email string `json:"email"`
175 Code string `json:"code"`
176 Link bool `json:"link"`
177 }
178 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
179 http.Error(w, "bad request", http.StatusBadRequest)
180 return
181 }
182 // Link mode's proof gate runs BEFORE the redeem β€” a missing proof must
183 // not burn a perfectly good single-use code.
184 var linkUID string
185 if req.Link {
186 uid, ok := s.ceremonyUser(r)
187 if !ok {
188 http.Error(w, "Confirm it's you first", http.StatusUnauthorized)
189 return
190 }
191 linkUID = uid
192 }
193 index := s.emailIndex(req.Email)
194 if len(req.Code) != 6 || !s.emailCodes.redeem(index, req.Code, time.Now()) {
195 http.Error(w, "That code didn't work", http.StatusUnauthorized)
196 return
197 }
198
199 if req.Link {
200 uid := linkUID
201 conflict, err := s.store.insertEmailAnchor(uid, index)
202 if err != nil {
203 http.Error(w, "db error", http.StatusInternalServerError)
204 return
205 }
206 if conflict {
207 http.Error(w, "That address already signs in somewhere else", http.StatusConflict)
208 return
209 }
210 writeJSON(w, http.StatusOK, map[string]any{"linked": true})
211 return
212 }
213
214 uid, ok := s.store.emailUser(index)
215 created := false
216 if !ok {
217 id, err := randToken()
218 if err != nil {
219 http.Error(w, "db error", http.StatusInternalServerError)
220 return
221 }
222 if err := s.store.createUser(id); err != nil {
223 http.Error(w, "db error", http.StatusInternalServerError)
224 return
225 }
226 if _, err := s.store.insertEmailAnchor(id, index); err != nil {
227 http.Error(w, "db error", http.StatusInternalServerError)
228 return
229 }
230 uid, created = id, true
231 }
232 tok, err := s.store.createLinkToken(uid)
233 if err != nil {
234 http.Error(w, "db error", http.StatusInternalServerError)
235 return
236 }
237 writeJSON(w, http.StatusOK, map[string]any{
238 "token": tok,
239 "proof": s.keys.mintCeremonyProof(uid),
240 "created": created,
241 })
242}
243