eleven / messenger Public

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>
Paul Campbell 3fa60fc4630c60baaf732f00c6ad4f955c8d4379 parent a695ddf
7 files changed, +660 −0
  • home/db.go +31 −0
  • home/email.go +222 −0
  • home/email_test.go +236 −0
  • home/main.go +24 −0
  • home/s3store.go +52 −0
  • home/ses.go +85 −0
  • home/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.
}