package main // ๐Ÿค– The email anchor โ€” sign in (or recover) with a typed 6-digit code // (issue #1091; design: docs/accounts-and-recovery.md ยง Email). Email is an // IDENTITY anchor exactly like Sign in with Apple: a verified code answers // with a device link token + a fresh ceremony proof and can never decrypt // anything. The store only ever holds the BLIND INDEX โ€” HMAC(pepper, // normalize(email)) โ€” never an address: at sign-in the person types their // address, we hash and match, and the mail goes to what they typed. The // running process and SES see the address transiently at send time; nothing // keeps it. // // Codes are in-memory (the #1054 precedent): 15-minute TTL, single-use, // attempt-capped. The request endpoint always answers the same way โ€” an // unknown address gets a code too (verify creates the account, the same // seamless front door as SiwA), which is also what makes account enumeration // impossible by construction. import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "net/url" "strings" "sync" "time" ) const ( emailCodeTTL = 15 * time.Minute emailCodeAttempts = 5 // emailCodeMinGap throttles per-address resends independently of the // per-IP limiter โ€” one address can't be made to spam its own inbox. emailCodeMinGap = 30 * time.Second ) // emailCodes is the in-memory single-use code store, keyed by the address's // blind index. One live code per address: a resend replaces it. type emailCodes struct { mu sync.Mutex codes map[string]*emailCode } type emailCode struct { code string expires time.Time sent time.Time attempts int } func newEmailCodes() *emailCodes { return &emailCodes{codes: map[string]*emailCode{}} } // issue mints a fresh code for the index unless one was sent too recently // (ok=false โ€” the caller still answers 200; the earlier code stays valid). func (c *emailCodes) issue(index string, now time.Time) (string, bool) { c.mu.Lock() defer c.mu.Unlock() if cur, ok := c.codes[index]; ok && now.Sub(cur.sent) < emailCodeMinGap { return "", false } var b [4]byte if _, err := rand.Read(b[:]); err != nil { return "", false } n := (uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % 1000000 code := fmt.Sprintf("%06d", n) c.codes[index] = &emailCode{code: code, expires: now.Add(emailCodeTTL), sent: now} return code, true } // redeem checks a code for the index: single-use on success, attempt-capped // and expiring on failure. Comparison is constant-shaped (same work either // way) though the 6-digit space makes the cap, not timing, the real defence. func (c *emailCodes) redeem(index, code string, now time.Time) bool { c.mu.Lock() defer c.mu.Unlock() cur, ok := c.codes[index] if !ok || now.After(cur.expires) { delete(c.codes, index) return false } cur.attempts++ if cur.attempts > emailCodeAttempts { delete(c.codes, index) return false } if hmac.Equal([]byte(cur.code), []byte(code)) { delete(c.codes, index) return true } return false } func (s *Server) emailEnabled() bool { return s.mail != nil && len(s.emailPepper) > 0 } // emailSignInLink is the magic link under the code: the SAME address and code // the mail asks you to type, carried in the URL #fragment โ€” which browsers // never send to any server. Clicking just runs the ordinary // /api/email/verify from the page, so the link adds zero server auth surface. // Encoding: QueryEscape with its space-"+" swapped to %20, which is exactly // what the page's decodeURIComponent inverts (a plus-addressed local part // round-trips: + โ†’ %2B โ†’ +). func emailSignInLink(origin, addr, code string) string { return origin + "/#email=" + strings.ReplaceAll(url.QueryEscape(addr), "+", "%20") + "&code=" + code } // emailCodeBody is the sign-in mail. The code stays first and prominent โ€” it // is the spine (works typed into any browser); the link is the same values as // a convenience tap. func emailCodeBody(origin, addr, code string) string { return "Your sign-in code is " + code + "\n\n" + "Or tap: " + emailSignInLink(origin, addr, code) + "\n\n" + "It works for 15 minutes. If you didn't ask for it, ignore this email." } // emailIndex is the blind index: HMAC(pepper, lowercase(address)). Lowercase // is the WHOLE normalization โ€” plus-addressing and provider dot-games are the // user's business, not ours to strip (per the design doc). func (s *Server) emailIndex(email string) string { m := hmac.New(sha256.New, s.emailPepper) m.Write([]byte(strings.ToLower(strings.TrimSpace(email)))) return hex.EncodeToString(m.Sum(nil)) } // emailRequest is POST /api/email/request โ€” send a sign-in code. The answer // is the same 200 whether or not the address is known (no enumeration), and // resend throttling is silent for the same reason. func (s *Server) emailRequest(w http.ResponseWriter, r *http.Request) { if !s.emailEnabled() { http.Error(w, "not found", http.StatusNotFound) return } var req struct { Email string `json:"email"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } addr := strings.TrimSpace(req.Email) if addr == "" || len(addr) > 254 || !strings.Contains(addr, "@") { http.Error(w, "bad request", http.StatusBadRequest) return } if code, ok := s.emailCodes.issue(s.emailIndex(addr), time.Now()); ok { // Send failures are surfaced to the caller as the same 200 โ€” an // attacker learns nothing, and the person retries when no mail // arrives. The error is logged for the operator. go func() { if err := s.mail.send(addr, "Your Eleven sign-in code", emailCodeBody(s.origin, addr, code)); err != nil { log.Printf("email code send failed: %v", err) } }() } writeJSON(w, http.StatusOK, map[string]any{"sent": true}) } // emailVerify is POST /api/email/verify โ€” redeem a code. Mirrors siwaVerify // exactly: sign-in resolves-or-creates the account behind the blind index and // answers like finishCeremony; link:true + X-Ceremony-Proof attaches the // email to an existing account (one email, one account; 409 on conflict). func (s *Server) emailVerify(w http.ResponseWriter, r *http.Request) { if !s.emailEnabled() { http.Error(w, "not found", http.StatusNotFound) return } var req struct { Email string `json:"email"` Code string `json:"code"` Link bool `json:"link"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } // Link mode's proof gate runs BEFORE the redeem โ€” a missing proof must // not burn a perfectly good single-use code. var linkUID string if req.Link { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "Confirm it's you first", http.StatusUnauthorized) return } linkUID = uid } index := s.emailIndex(req.Email) if len(req.Code) != 6 || !s.emailCodes.redeem(index, req.Code, time.Now()) { http.Error(w, "That code didn't work", http.StatusUnauthorized) return } if req.Link { uid := linkUID conflict, err := s.store.insertEmailAnchor(uid, index) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if conflict { http.Error(w, "That address already signs in somewhere else", http.StatusConflict) return } writeJSON(w, http.StatusOK, map[string]any{"linked": true}) return } uid, ok := s.store.emailUser(index) created := false if !ok { id, err := randToken() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if err := s.store.createUser(id); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.store.insertEmailAnchor(id, index); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } uid, created = id, true } tok, err := s.store.createLinkToken(uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{ "token": tok, "proof": s.keys.mintCeremonyProof(uid), "created": created, }) }