| 1 | package 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 | |
| 19 | import ( |
| 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 | |
| 34 | const ( |
| 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. |
| 44 | type emailCodes struct { |
| 45 | mu sync.Mutex |
| 46 | codes map[string]*emailCode |
| 47 | } |
| 48 | |
| 49 | type emailCode struct { |
| 50 | code string |
| 51 | expires time.Time |
| 52 | sent time.Time |
| 53 | attempts int |
| 54 | } |
| 55 | |
| 56 | func 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). |
| 60 | func (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. |
| 79 | func (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 | |
| 99 | func (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 β +). |
| 108 | func 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. |
| 115 | func 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). |
| 124 | func (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. |
| 133 | func (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). |
| 168 | func (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 | |