home: the email anchor — sign in with a typed code, sent via SES (#1091) (#1092)
* home: Sign in with Apple — the identity-anchor front door (#1089) The one verifier for every platform: POST /api/siwa/verify checks Apple's identity token (hand-rolled RS256 + JWKS cache, the apns.go style — no new deps), keys accounts on HMAC(pepper, sub), and answers exactly like finishCeremony (link token + ceremony proof). link:true + X-Ceremony-Proof anchors an Apple ID to an existing account (same freshness bar as adding a passkey; one account per Apple ID, 409 on conflict). POST /siwa/return is the web flow's form_post trampoline — bounces the token to the app over the custom scheme, verifying nothing, so there is exactly one verifier. siwa/<subHash> anchors land in both stores (SQLite table + S3 pointer/doc pair). Feature is off unless BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER are set — the pepper names accounts, so no ephemeral fallback exists by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * home: the email anchor — sign in with a typed code, sent via SES (#1091) Email joins SiwA as an identity anchor, mirroring it exactly: verify answers like finishCeremony (link token + proof), link:true attaches to an existing account behind the same ceremony-proof bar (proof gate runs BEFORE the redeem so a missing proof never burns a code), one address per account. The store only ever holds the blind index — HMAC(pepper, lowercase(address)) — never an address; mail goes to what the person typed. Codes: in-memory, 15-min, single-use, attempt-capped, silent 30s resend gap; the request endpoint answers identically for known and unknown addresses (no enumeration — unknown ones create the account at verify, the same seamless front door). SES v2 via the existing SigV4 signer — no SDK, no new deps. Off unless LCHAT_HOME_EMAIL_FROM + LCHAT_HOME_SES_REGION + LCHAT_HOME_EMAIL_PEPPER are all set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+660
−0
home/db.go+31 −0home/email.go+222 −0home/email_test.go+236 −0home/main.go+24 −0home/s3store.go+52 −0home/ses.go+85 −0home/store.go+10 −0
diff --git a/home/db.go b/home/db.go| index c4a7dcb..6d0f8aa 100644 |
| --- a/home/db.go |
| +++ b/home/db.go |
| @@ -98,6 +98,13 @@ CREATE TABLE IF NOT EXISTS siwa_anchors ( |
| sub_hash TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +); |
| +-- Email anchors (email.go): only the blind index — HMAC(pepper, address) — |
| +-- is ever stored; no plaintext address exists anywhere at rest. |
| +CREATE TABLE IF NOT EXISTS email_anchors ( |
| + email_hash TEXT PRIMARY KEY, |
| + user_id TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| );`) |
| return err |
| } |
| @@ -128,6 +135,30 @@ func (s *sqliteStore) insertSiwaAnchor(uid, subHash string) (bool, error) { |
| return owner != uid, nil |
| } |
| +func (s *sqliteStore) emailUser(index string) (string, bool) { |
| + var uid string |
| + err := s.sql.QueryRow(`SELECT user_id FROM email_anchors WHERE email_hash = ?`, index).Scan(&uid) |
| + if err != nil { |
| + return "", false |
| + } |
| + return uid, true |
| +} |
| + |
| +func (s *sqliteStore) insertEmailAnchor(uid, index string) (bool, error) { |
| + // Same shape as insertSiwaAnchor: win the row or read who holds it. |
| + if _, err := s.sql.Exec( |
| + `INSERT INTO email_anchors (email_hash, user_id, created_at) VALUES (?, ?, ?) |
| + ON CONFLICT (email_hash) DO NOTHING`, |
| + index, uid, time.Now().Unix()); err != nil { |
| + return false, err |
| + } |
| + owner, ok := s.emailUser(index) |
| + if !ok { |
| + return false, errors.New("email anchor vanished") |
| + } |
| + return owner != uid, nil |
| +} |
| + |
| func hashToken(t string) string { |
| h := sha256.Sum256([]byte(t)) |
| return hex.EncodeToString(h[:]) |
diff --git a/home/email.go b/home/email.go| new file mode 100644 |
| index 0000000..9315bc1 |
| --- /dev/null |
| +++ b/home/email.go |
| @@ -0,0 +1,222 @@ |
| +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" |
| + "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 } |
| + |
| +// 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", |
| + "Your sign-in code is "+code+"\n\n"+ |
| + "It works for 15 minutes. If you didn't ask for it, ignore this email."); 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, |
| + }) |
| +} |
diff --git a/home/email_test.go b/home/email_test.go| new file mode 100644 |
| index 0000000..57720f5 |
| --- /dev/null |
| +++ b/home/email_test.go |
| @@ -0,0 +1,236 @@ |
| +package main |
| + |
| +// 🤖 Email-anchor tests (email.go): a fake SES — an httptest server capturing |
| +// SendEmail bodies — plays the mail provider, so the full request→redeem path |
| +// runs with no AWS and no network. |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "testing" |
| + "time" |
| +) |
| + |
| +var codeRe = regexp.MustCompile(`\b(\d{6})\b`) |
| + |
| +// emailServer is newTestServer with the email anchor on, wired to a fake SES |
| +// that pushes each sent (to, code) onto a channel. |
| +func emailServer(t *testing.T) (*httptest.Server, *Server, chan [2]string) { |
| + t.Helper() |
| + ts, s := newTestServer(t) |
| + sent := make(chan [2]string, 8) |
| + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + body, _ := io.ReadAll(r.Body) |
| + var req struct { |
| + Destination struct { |
| + ToAddresses []string `json:"ToAddresses"` |
| + } `json:"Destination"` |
| + Content struct { |
| + Simple struct { |
| + Body struct { |
| + Text struct { |
| + Data string `json:"Data"` |
| + } `json:"Text"` |
| + } `json:"Body"` |
| + } `json:"Simple"` |
| + } `json:"Content"` |
| + } |
| + if err := json.Unmarshal(body, &req); err != nil || len(req.Destination.ToAddresses) != 1 { |
| + t.Errorf("fake ses: bad body %s", body) |
| + } |
| + m := codeRe.FindStringSubmatch(req.Content.Simple.Body.Text.Data) |
| + if m == nil { |
| + t.Errorf("fake ses: no code in %q", req.Content.Simple.Body.Text.Data) |
| + m = []string{"", ""} |
| + } |
| + sent <- [2]string{req.Destination.ToAddresses[0], m[1]} |
| + writeJSON(w, http.StatusOK, map[string]string{"MessageId": "fake"}) |
| + })) |
| + t.Cleanup(fake.Close) |
| + s.mail = &sesMailer{region: "eu-west-1", from: "sign-in@test", accessKey: "k", secretKey: "s", endpoint: fake.URL} |
| + s.emailPepper = []byte("test-email-pepper") |
| + s.emailCodes = newEmailCodes() |
| + return ts, s, sent |
| +} |
| + |
| +func requestCode(t *testing.T, ts *httptest.Server, sent chan [2]string, addr string) string { |
| + t.Helper() |
| + b, _ := json.Marshal(map[string]string{"email": addr}) |
| + resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("request: got %d", resp.StatusCode) |
| + } |
| + select { |
| + case m := <-sent: |
| + if m[0] != addr { |
| + t.Fatalf("mail went to %q, want %q", m[0], addr) |
| + } |
| + return m[1] |
| + case <-time.After(2 * time.Second): |
| + t.Fatalf("no mail arrived for %s", addr) |
| + return "" |
| + } |
| +} |
| + |
| +func verifyCode(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response { |
| + t.Helper() |
| + b, _ := json.Marshal(body) |
| + return do(t, "POST", ts.URL+"/api/email/verify", "", headers, b) |
| +} |
| + |
| +func TestEmailDisabledIs404(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, []byte(`{"email":"a@b.c"}`)) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("disabled: got %d, want 404", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestEmailSignInCreatesThenResolvesAccount(t *testing.T) { |
| + ts, srv, sent := emailServer(t) |
| + |
| + code := requestCode(t, ts, sent, "paul@example.com") |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("verify: got %d", resp.StatusCode) |
| + } |
| + var first struct { |
| + Token string `json:"token"` |
| + Proof string `json:"proof"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if first.Token == "" || first.Proof == "" || !first.Created { |
| + t.Fatalf("first: %+v", first) |
| + } |
| + put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed")) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("blob put: got %d", put.StatusCode) |
| + } |
| + |
| + // The code is single-use. |
| + resp = verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("reuse: got %d, want 401", resp.StatusCode) |
| + } |
| + |
| + // Address case-folds to the same account; a second code reaches the same |
| + // vault. (The 30s resend gap applies per-index — reach into the code |
| + // store and age the entry, the seed-state-directly convention.) |
| + idx := srv.emailIndex("PAUL@example.com") |
| + srv.emailCodes.mu.Lock() |
| + if cur, ok := srv.emailCodes.codes[idx]; ok { |
| + cur.sent = time.Now().Add(-time.Minute) |
| + } |
| + srv.emailCodes.mu.Unlock() |
| + code2 := requestCode(t, ts, sent, "PAUL@example.com") |
| + resp = verifyCode(t, ts, nil, map[string]any{"email": "PAUL@example.com", "code": code2}) |
| + var second struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&second); err != nil { |
| + t.Fatalf("decode second: %v", err) |
| + } |
| + if second.Created { |
| + t.Fatalf("case-folded address created a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) |
| + if b, _ := io.ReadAll(get.Body); string(b) != "sealed" { |
| + t.Fatalf("second token reads a different vault: %q", b) |
| + } |
| +} |
| + |
| +func TestEmailWrongCodeAndAttemptCap(t *testing.T) { |
| + ts, s, sent := emailServer(t) |
| + code := requestCode(t, ts, sent, "cap@example.com") |
| + for i := 0; i < emailCodeAttempts; i++ { |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": "000000"}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("wrong code: got %d", resp.StatusCode) |
| + } |
| + } |
| + // The cap has burned the code: even the right one no longer works. |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": code}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("post-cap right code: got %d, want 401", resp.StatusCode) |
| + } |
| + _ = s |
| +} |
| + |
| +func TestEmailCodeExpiry(t *testing.T) { |
| + ts, s, sent := emailServer(t) |
| + code := requestCode(t, ts, sent, "old@example.com") |
| + idx := s.emailIndex("old@example.com") |
| + s.emailCodes.mu.Lock() |
| + s.emailCodes.codes[idx].expires = time.Now().Add(-time.Second) |
| + s.emailCodes.mu.Unlock() |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": "old@example.com", "code": code}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("expired code: got %d, want 401", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestEmailResendGapIsSilent(t *testing.T) { |
| + ts, _, sent := emailServer(t) |
| + _ = requestCode(t, ts, sent, "gap@example.com") |
| + // An immediate second request: same 200, no second mail. |
| + b, _ := json.Marshal(map[string]string{"email": "gap@example.com"}) |
| + resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("resend: got %d, want the same 200", resp.StatusCode) |
| + } |
| + select { |
| + case m := <-sent: |
| + t.Fatalf("resend inside the gap sent mail: %v", m) |
| + case <-time.After(300 * time.Millisecond): |
| + } |
| +} |
| + |
| +func TestEmailLinkToExistingAccount(t *testing.T) { |
| + ts, s, sent := emailServer(t) |
| + uid, token := seedUser(t, s) |
| + proof := s.keys.mintCeremonyProof(uid) |
| + |
| + code := requestCode(t, ts, sent, "link@example.com") |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code, "link": true}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) |
| + } |
| + // The proofless attempt must NOT have burned the code — the proof gate |
| + // runs before the redeem, so the same code now links fine. |
| + resp = verifyCode(t, ts, map[string]string{"X-Ceremony-Proof": proof}, |
| + map[string]any{"email": "link@example.com", "code": code, "link": true}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("link with proof: got %d", resp.StatusCode) |
| + } |
| + |
| + put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded")) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("seed blob: got %d", put.StatusCode) |
| + } |
| + s.emailCodes.mu.Lock() |
| + delete(s.emailCodes.codes, s.emailIndex("link@example.com")) |
| + s.emailCodes.mu.Unlock() |
| + code = requestCode(t, ts, sent, "link@example.com") |
| + resp = verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code}) |
| + var signin struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if signin.Created { |
| + t.Fatalf("post-link sign-in created a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) |
| + if b, _ := io.ReadAll(get.Body); string(b) != "seeded" { |
| + t.Fatalf("linked sign-in reads a different vault: %q", b) |
| + } |
| +} |
diff --git a/home/main.go b/home/main.go| index 60edb35..48115e6 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -55,6 +55,11 @@ type Server struct { |
| siwaClientIDs []string |
| siwaPepper []byte |
| siwa *jwksCache |
| + // The email anchor (email.go): the SES mailer, the blind-index pepper, |
| + // and the in-memory sign-in codes. mail nil or pepper empty → off (404). |
| + mail *sesMailer |
| + emailPepper []byte |
| + emailCodes *emailCodes |
| // cloud is the optional space provisioner client (see provision.go); nil |
| // means this home server doesn't offer space creation. When regions is |
| // non-empty (per-country provisioning), this points at the DEFAULT region's |
| @@ -147,6 +152,22 @@ func main() { |
| s.siwa = &jwksCache{url: appleJWKSURL} |
| log.Printf("Sign in with Apple on (%d client ids)", len(s.siwaClientIDs)) |
| } |
| + // The email anchor: LCHAT_HOME_EMAIL_FROM + LCHAT_HOME_SES_REGION turn |
| + // it on (plus AWS creds in env); LCHAT_HOME_EMAIL_PEPPER blind-indexes |
| + // addresses and, like the SiwA pepper, names accounts — durable, env-only, |
| + // no ephemeral fallback. All three or none. |
| + emailFrom, sesRegion, emailPepper := os.Getenv("LCHAT_HOME_EMAIL_FROM"), os.Getenv("LCHAT_HOME_SES_REGION"), os.Getenv("LCHAT_HOME_EMAIL_PEPPER") |
| + if n := map[bool]int{true: 1}[emailFrom != ""] + map[bool]int{true: 1}[sesRegion != ""] + map[bool]int{true: 1}[emailPepper != ""]; n != 0 && n != 3 { |
| + log.Fatalf("the email anchor needs ALL of LCHAT_HOME_EMAIL_FROM, LCHAT_HOME_SES_REGION, LCHAT_HOME_EMAIL_PEPPER (or none)") |
| + } |
| + if emailFrom != "" { |
| + mailer, err := newSESMailer(sesRegion, emailFrom) |
| + if err != nil { |
| + log.Fatalf("email anchor: %v", err) |
| + } |
| + s.mail, s.emailPepper, s.emailCodes = mailer, []byte(emailPepper), newEmailCodes() |
| + log.Printf("email sign-in on (from %s via SES %s)", emailFrom, sesRegion) |
| + } |
| if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { |
| log.Printf("space provisioning via %s", s.cloud.url) |
| } |
| @@ -337,6 +358,9 @@ func (s *Server) handler() http.Handler { |
| // Apple's identity token to, and the web flow's form_post trampoline. |
| mux.HandleFunc("POST /api/siwa/verify", s.rateLimited(s.siwaVerify)) |
| mux.HandleFunc("POST /siwa/return", s.rateLimited(s.siwaReturn)) |
| + // The email anchor (email.go): request a sign-in code, redeem it. |
| + mux.HandleFunc("POST /api/email/request", s.rateLimited(s.emailRequest)) |
| + mux.HandleFunc("POST /api/email/verify", s.rateLimited(s.emailVerify)) |
| // Enrolment completion oracle (enrolstatus.go): /enroll reports a finished |
| // ceremony; the instance page that launched it polls for the answer. The |
diff --git a/home/s3store.go b/home/s3store.go| index 290973d..e12d193 100644 |
| --- a/home/s3store.go |
| +++ b/home/s3store.go |
| @@ -184,6 +184,7 @@ func (s *s3Store) userKey(uid string) string { return s.prefix + "users/" + uid |
| func (s *s3Store) credKey(id string) string { return s.prefix + "creds/" + id } |
| func (s *s3Store) tokenKey(h string) string { return s.prefix + "tokens/" + h } |
| func (s *s3Store) siwaKey(h string) string { return s.prefix + "siwa/" + h } |
| +func (s *s3Store) emailKey(h string) string { return s.prefix + "emails/" + h } |
| // ---- the blob ----------------------------------------------------------------- |
| @@ -366,6 +367,8 @@ type s3UserDoc struct { |
| // doc-side half of the siwa/<subHash> pointers (pointers never decide; |
| // see the vaultStore contract). |
| SiwaAnchors []s3SiwaAnchor `json:"siwa_anchors,omitempty"` |
| + // EmailAnchors mirrors SiwaAnchors for the email blind indexes. |
| + EmailAnchors []s3EmailAnchor `json:"email_anchors,omitempty"` |
| } |
| type s3SiwaAnchor struct { |
| @@ -373,6 +376,11 @@ type s3SiwaAnchor struct { |
| CreatedAt int64 `json:"created_at"` |
| } |
| +type s3EmailAnchor struct { |
| + EmailHash string `json:"email_hash"` |
| + CreatedAt int64 `json:"created_at"` |
| +} |
| + |
| type s3Device struct { |
| ID string `json:"id"` |
| TokenHash string `json:"token_hash"` |
| @@ -681,6 +689,50 @@ func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) { |
| return false, nil |
| } |
| +// emailUser / insertEmailAnchor mirror the siwa pair exactly — pointer plus |
| +// doc entry, pointer never decides. |
| +func (s *s3Store) emailUser(index string) (string, bool) { |
| + uid, found, err := s.getPointer(s.emailKey(index)) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + doc, _, found, err := s.loadUser(uid) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + for _, a := range doc.EmailAnchors { |
| + if a.EmailHash == index { |
| + return uid, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +func (s *s3Store) insertEmailAnchor(uid, index string) (bool, error) { |
| + if owner, found, err := s.getPointer(s.emailKey(index)); err != nil { |
| + return false, err |
| + } else if found && owner != uid { |
| + return true, nil |
| + } |
| + if err := s.putPointer(s.emailKey(index), uid); err != nil { |
| + return false, err |
| + } |
| + now := time.Now().Unix() |
| + err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { |
| + for _, a := range doc.EmailAnchors { |
| + if a.EmailHash == index { |
| + return false, nil |
| + } |
| + } |
| + doc.EmailAnchors = append(doc.EmailAnchors, s3EmailAnchor{EmailHash: index, CreatedAt: now}) |
| + return true, nil |
| + }) |
| + if err != nil { |
| + return false, err |
| + } |
| + return false, nil |
| +} |
| + |
| // linkTokenUser resolves tokens/<hash> and verifies the device is still in |
| // the user doc (revocation = removal from the doc; the pointer never decides). |
| // last_seen bumps only when more than lastSeenGranularity stale — that |
diff --git a/home/ses.go b/home/ses.go| new file mode 100644 |
| index 0000000..4027a51 |
| --- /dev/null |
| +++ b/home/ses.go |
| @@ -0,0 +1,85 @@ |
| +package main |
| + |
| +// 🤖 A minimal SES v2 sender for the email anchor's sign-in codes (issue |
| +// #1091) — one SigV4-signed POST to the SendEmail API, riding the same |
| +// hand-rolled signer the S3 store uses (sigv4.go). No SDK: the whole mailer |
| +// is readable in one sitting, and home's dependency set stays boring. |
| +// |
| +// Deliverability (SPF/DKIM, sandbox exit, bounce handling) is SES-console |
| +// and DNS work, deliberately outside this file — see issue #1091's ops list. |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "os" |
| + "time" |
| +) |
| + |
| +// sesMailer sends through SES v2 in one region from one address. endpoint is |
| +// overridable for tests (a fake SES httptest server); empty means the real |
| +// regional endpoint. |
| +type sesMailer struct { |
| + region string |
| + from string |
| + accessKey string |
| + secretKey string |
| + endpoint string |
| +} |
| + |
| +func newSESMailer(region, from string) (*sesMailer, error) { |
| + access, secret := os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY") |
| + if access == "" || secret == "" { |
| + return nil, errors.New("email anchor: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set") |
| + } |
| + return &sesMailer{region: region, from: from, accessKey: access, secretKey: secret}, nil |
| +} |
| + |
| +// send delivers one plain-text email. The recipient address is used for this |
| +// send and then dropped — nothing here persists it (the blind-index rule). |
| +func (m *sesMailer) send(to, subject, text string) error { |
| + body, err := json.Marshal(map[string]any{ |
| + "FromEmailAddress": m.from, |
| + "Destination": map[string]any{"ToAddresses": []string{to}}, |
| + "Content": map[string]any{ |
| + "Simple": map[string]any{ |
| + "Subject": map[string]any{"Data": subject, "Charset": "UTF-8"}, |
| + "Body": map[string]any{ |
| + "Text": map[string]any{"Data": text, "Charset": "UTF-8"}, |
| + }, |
| + }, |
| + }, |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + endpoint := m.endpoint |
| + if endpoint == "" { |
| + endpoint = "https://email." + m.region + ".amazonaws.com" |
| + } |
| + req, err := http.NewRequest("POST", endpoint+"/v2/email/outbound-emails", bytes.NewReader(body)) |
| + if err != nil { |
| + return err |
| + } |
| + now := time.Now() |
| + digest := sha256.Sum256(body) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("X-Amz-Date", now.UTC().Format("20060102T150405Z")) |
| + req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(digest[:])) |
| + awsSignV4(req, m.accessKey, m.secretKey, m.region, "ses", now) |
| + resp, err := siwaHTTP.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode/100 != 2 { |
| + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| + return fmt.Errorf("ses: %s: %s", resp.Status, b) |
| + } |
| + return nil |
| +} |
diff --git a/home/store.go b/home/store.go| index a03070e..a629b1a 100644 |
| --- a/home/store.go |
| +++ b/home/store.go |
| @@ -108,6 +108,16 @@ type vaultStore interface { |
| // anchored to a DIFFERENT user — an Apple ID signs into one account. |
| insertSiwaAnchor(uid, subHash string) (conflict bool, err error) |
| + // ---- email anchors (emails/<blind index> pointers) ---- |
| + |
| + // emailUser resolves an address's blind index (HMAC(pepper, |
| + // lowercase(email)), hex) to its user. ok=false when unanchored. |
| + emailUser(index string) (uid string, ok bool) |
| + // insertEmailAnchor stores the emails/<index> pointer. Idempotent for the |
| + // same user; conflict=true (nothing written) when the address is already |
| + // anchored to a DIFFERENT user. |
| + insertEmailAnchor(uid, index string) (conflict bool, err error) |
| + |
| // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| // tokens (stateless.go) — a store that persists nothing needs no methods. |
| } |