eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The Eleven home server: a tiny, anonymous, content-blind, passkey-gated blob
2// store (docs/onboarding-and-home-server.md). It holds, per user: WebAuthn
3// credential public keys, ONE opaque client-encrypted blob (padded client-side
4// so length leaks nothing), and hashed device link tokens. No email, no
5// username, no display name — the credential is the identity. It is strictly
6// additive to the messenger: clients with no enrolment never contact it.
7package main
8
9import (
10 "bytes"
11 "crypto/sha256"
12 "embed"
13 "encoding/base64"
14 "encoding/json"
15 "errors"
16 "flag"
17 "fmt"
18 "io"
19 "io/fs"
20 "log"
21 "net"
22 "net/http"
23 "net/url"
24 "os"
25 "strconv"
26 "strings"
27 "sync"
28 "time"
29)
30
31// maxBlobBytes caps a blob upload. The client pads the plaintext to a fixed
32// 16 KiB before encrypting (see web/static/crypto.js), so every blob's
33// ciphertext is the same size; the extra headroom here is the envelope header
34// (one wrapped DEK per enrolled passkey) plus AES-GCM overhead. The cap is a
35// hard abuse control, not a privacy boundary — sizes below it are already
36// uniform by construction.
37const maxBlobBytes = 18 << 10
38
39//go:embed web/pages/*.html web/static/*
40var webFS embed.FS
41
42type Server struct {
43 // store is the persistence seam (vaultStore, store.go) — SQLite today, an
44 // object store later; handlers never know which.
45 store vaultStore
46 wa *waRelyingParty
47 // keys mints/verifies home's stateless auth tokens (stateless.go): HMAC
48 // ceremony proofs and AES-GCM-sealed in-flight WebAuthn sessions. Nothing
49 // per-ceremony is held in this process — any replica can finish what
50 // another began.
51 keys *stateKeys
52 // limiter rate-limits the ceremony begin endpoints per IP — the abuse
53 // surface of an open, anonymous registration endpoint.
54 limiter *rateLimiter
55 // Sign in with Apple (siwa.go): the accepted token audiences (iOS bundle
56 // id + web Services ID), the account-keying pepper, and Apple's JWKS
57 // cache. Both empty/nil → the endpoints answer 404 (feature off).
58 siwaClientIDs []string
59 siwaPepper []byte
60 siwa *jwksCache
61 // oidc is the generalized provider table (oidc.go) — the Google and
62 // Microsoft doors, keyed by route name. Each row gates like SiwA (client
63 // ids + pepper, both or neither); empty/nil → every /api/oidc/* and
64 // /oidc/return answers 404. Apple stays on the dedicated fields above.
65 oidc map[string]*oidcProvider
66 github *githubDoor // the code-exchange door (github.go); nil = dark
67 // The email anchor (email.go): the SES mailer, the blind-index pepper,
68 // and the in-memory sign-in codes. mail nil or pepper empty → off (404).
69 mail *sesMailer
70 emailPepper []byte
71 emailCodes *emailCodes
72 // cloud is the optional space provisioner client (see provision.go); nil
73 // means this home server doesn't offer space creation. When regions is
74 // non-empty (per-country provisioning), this points at the DEFAULT region's
75 // provisioner, so demo and every host-agnostic fallback keep working.
76 cloud *cloudProvisioner
77 // regions, when LCHAT_CLOUD_REGIONS names more than one provisioner, lets a
78 // signed-in user's new space be placed per country (docs/dev-mesh.md). Empty
79 // means single-region: s.cloud is the only provisioner. defaultRegion is the
80 // region code a create with no explicit choice lands in.
81 regions []cloudRegion
82 defaultRegion string
83 // pow gates space creation behind hashcash when LCHAT_POW_BITS > 0
84 // (pow.go); nil = no gate.
85 pow *powGate
86 // demo is the account-free demo-space surface and its abuse gates
87 // (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset).
88 demo *demoGate
89 // fx caches the ECB reference rates behind the picker's ≈ display
90 // (fx.go) — zero value ready (empty until the loop's first fetch).
91 fx fxCache
92 // pricebook caches the provisioner's public price book for the
93 // unauthenticated /api/pricebook (pricebook.go) — zero value ready.
94 pricebook pricebookCache
95 // demoStatus caches the countdown banner's live status reads (demo.go) —
96 // zero value ready.
97 demoStatus demoStatusCache
98 // origin is this server's public origin (also the WebAuthn RP origin) —
99 // used to build the checkout return URLs.
100 origin string
101 page []byte
102 pageHashes string // 'sha256-…' CSP sources for the page's inline scripts
103 enrollPage []byte
104 restorePage []byte
105 // enrol remembers recently-completed enrolments by nonce (enrolstatus.go)
106 // so the page that launched one can poll for the finish. In-memory: if this
107 // server ever runs replicated, move the marks into the store.
108 enrol *enrolMarks
109 // infrastructureDir is an optional on-disk directory serving the
110 // planet-scale operations guide (docs/planet-scale-guide/ in the repo) at
111 // /infrastructure/ — read from disk, not embedded: go:embed can't reach
112 // outside this package's own directory tree without duplicating the
113 // guide's source, so a deploy step copies it here instead (see
114 // docs/planet-scale-guide's own deploy note). Empty means the route is
115 // off entirely — most self-hosted deployments have no reason to publish it.
116 infrastructureDir string
117}
118
119func main() {
120 addr := flag.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)")
121 dbPath := flag.String("db", envOr("LCHAT_HOME_DB", "home.db"), "path to SQLite database file (env: LCHAT_HOME_DB)")
122 origin := flag.String("origin", envOr("LCHAT_HOME_ORIGIN", "http://localhost:1748"),
123 "public origin, e.g. https://home.elevenmessenger.com (env: LCHAT_HOME_ORIGIN) — WebAuthn RP identity")
124 cloudURL := flag.String("cloud-url", os.Getenv("LCHAT_CLOUD_URL"),
125 "space provisioner base URL, e.g. https://cloud.elevenmessenger.com (env: LCHAT_CLOUD_URL); empty disables space creation")
126 cloudToken := flag.String("cloud-token", os.Getenv("LCHAT_CLOUD_TOKEN"),
127 "caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)")
128 flag.Parse()
129
130 store, storeDesc, err := openStore(*dbPath)
131 if err != nil {
132 log.Fatalf("open store: %v", err)
133 }
134 // LCHAT_HOME_SESSION_KEY signs/seals home's stateless ceremony proofs and
135 // in-flight WebAuthn sessions (stateless.go). It is auth plumbing, NOT a
136 // decryption key for any vault. Any long random string works (e.g.
137 // `openssl rand -base64 32`); it only needs setting when several replicas
138 // must honour each other's tokens. Env-only, deliberately not a flag —
139 // secrets don't belong in process listings.
140 secret := []byte(os.Getenv("LCHAT_HOME_SESSION_KEY"))
141 if len(secret) == 0 {
142 log.Printf("LCHAT_HOME_SESSION_KEY not set; using an ephemeral key — in-flight ceremonies won't survive a restart, and multiple replicas need a shared key")
143 }
144 s, err := newServer(store, *origin, secret)
145 if err != nil {
146 log.Fatalf("server: %v", err)
147 }
148 // Sign in with Apple: LCHAT_HOME_SIWA_CLIENT_IDS (comma-separated token
149 // audiences — the iOS bundle id and the web Services ID) turns the
150 // feature on, and LCHAT_HOME_SIWA_PEPPER keys accounts as
151 // HMAC(pepper, sub). The pepper is env-only (a secret) and, unlike the
152 // session key, must be DURABLE — it names accounts, so an ephemeral
153 // fallback would orphan every SiwA account on restart. Both or neither.
154 siwaClients, siwaPepper := os.Getenv("LCHAT_HOME_SIWA_CLIENT_IDS"), os.Getenv("LCHAT_HOME_SIWA_PEPPER")
155 if (siwaClients == "") != (siwaPepper == "") {
156 log.Fatalf("Sign in with Apple needs BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER (or neither)")
157 }
158 if siwaClients != "" {
159 for _, c := range strings.Split(siwaClients, ",") {
160 if c = strings.TrimSpace(c); c != "" {
161 s.siwaClientIDs = append(s.siwaClientIDs, c)
162 }
163 }
164 s.siwaPepper = []byte(siwaPepper)
165 s.siwa = &jwksCache{url: appleJWKSURL}
166 log.Printf("Sign in with Apple on (%d client ids)", len(s.siwaClientIDs))
167 }
168 // The email anchor: LCHAT_HOME_EMAIL_FROM + LCHAT_HOME_SES_REGION turn
169 // it on (plus AWS creds in env); LCHAT_HOME_EMAIL_PEPPER blind-indexes
170 // addresses and, like the SiwA pepper, names accounts — durable, env-only,
171 // no ephemeral fallback. All three or none.
172 emailFrom, sesRegion, emailPepper := os.Getenv("LCHAT_HOME_EMAIL_FROM"), os.Getenv("LCHAT_HOME_SES_REGION"), os.Getenv("LCHAT_HOME_EMAIL_PEPPER")
173 if n := map[bool]int{true: 1}[emailFrom != ""] + map[bool]int{true: 1}[sesRegion != ""] + map[bool]int{true: 1}[emailPepper != ""]; n != 0 && n != 3 {
174 log.Fatalf("the email anchor needs ALL of LCHAT_HOME_EMAIL_FROM, LCHAT_HOME_SES_REGION, LCHAT_HOME_EMAIL_PEPPER (or none)")
175 }
176 if emailFrom != "" {
177 mailer, err := newSESMailer(sesRegion, emailFrom)
178 if err != nil {
179 log.Fatalf("email anchor: %v", err)
180 }
181 s.mail, s.emailPepper, s.emailCodes = mailer, []byte(emailPepper), newEmailCodes()
182 log.Printf("email sign-in on (from %s via SES %s)", emailFrom, sesRegion)
183 }
184 // The OIDC provider table (oidc.go): each door rides the SiwA pattern —
185 // LCHAT_HOME_<P>_CLIENT_IDS (comma-separated token audiences) turns it on
186 // and LCHAT_HOME_<P>_PEPPER keys its accounts as HMAC(pepper, sub). The
187 // peppers are per provider, env-only, and DURABLE (they name accounts; no
188 // ephemeral fallback). Both or neither, per provider. Apple keeps its own
189 // LCHAT_HOME_SIWA_* pair above.
190 for _, pc := range []struct{ name, idsEnv, pepperEnv string }{
191 {"google", "LCHAT_HOME_GOOGLE_CLIENT_IDS", "LCHAT_HOME_GOOGLE_PEPPER"},
192 {"microsoft", "LCHAT_HOME_MICROSOFT_CLIENT_IDS", "LCHAT_HOME_MICROSOFT_PEPPER"},
193 } {
194 ids, pepper := os.Getenv(pc.idsEnv), os.Getenv(pc.pepperEnv)
195 if (ids == "") != (pepper == "") {
196 log.Fatalf("sign in with %s needs BOTH %s and %s (or neither)", pc.name, pc.idsEnv, pc.pepperEnv)
197 }
198 if ids == "" {
199 continue
200 }
201 p := newOIDCProvider(pc.name, splitCommaList(ids), []byte(pepper))
202 if s.oidc == nil {
203 s.oidc = map[string]*oidcProvider{}
204 }
205 s.oidc[pc.name] = p
206 log.Printf("sign in with %s on (%d client ids)", p.display, len(p.clientIDs))
207 }
208 // GitHub is the one code-flow door (github.go): a single client id +
209 // SECRET per instance (the prod and dev homes are separate GitHub OAuth
210 // apps), plus the usual durable pepper. All three or none.
211 ghID, ghSecret, ghPepper := os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID"),
212 os.Getenv("LCHAT_HOME_GITHUB_CLIENT_SECRET"), os.Getenv("LCHAT_HOME_GITHUB_PEPPER")
213 if (ghID == "") != (ghSecret == "") || (ghID == "") != (ghPepper == "") {
214 log.Fatal("sign in with GitHub needs LCHAT_HOME_GITHUB_CLIENT_ID, _CLIENT_SECRET, and _PEPPER together (or none)")
215 }
216 if ghID != "" {
217 s.github = &githubDoor{clientID: ghID, secret: ghSecret, pepper: []byte(ghPepper)}
218 log.Printf("sign in with GitHub on")
219 }
220 if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
221 log.Printf("space provisioning via %s", s.cloud.url)
222 }
223 // Per-country provisioning: LCHAT_CLOUD_REGIONS (a JSON array of
224 // {code,name,url,token}) plus LCHAT_CLOUD_DEFAULT_REGION. When set it
225 // supersedes the single LCHAT_CLOUD_URL — s.cloud becomes the default
226 // region's provisioner so demo and fallbacks are unchanged.
227 if regions, defaultCode, err := parseCloudRegions(os.Getenv("LCHAT_CLOUD_REGIONS"), os.Getenv("LCHAT_CLOUD_DEFAULT_REGION")); err != nil {
228 log.Fatalf("cloud regions: %v", err)
229 } else if len(regions) > 0 {
230 s.regions, s.defaultRegion = regions, defaultCode
231 s.cloud = s.cloudForRegion(defaultCode)
232 log.Printf("space provisioning across %d regions (default %s)", len(regions), defaultCode)
233 }
234 if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 {
235 s.pow = newPowGate(bits, s.keys)
236 log.Printf("space creation gated by proof of work (%d bits)", bits)
237 }
238 if os.Getenv("LCHAT_HOME_DEMO") == "1" {
239 bits, _ := strconv.Atoi(envOr("LCHAT_DEMO_POW_BITS", "18"))
240 gate, err := newDemoGate(bits, s.keys)
241 if err != nil {
242 log.Fatalf("demo gate: %v", err)
243 }
244 s.demo = gate
245 log.Printf("demo spaces on (mandatory proof of work, base %d bits, adaptive)", bits)
246 }
247 if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" {
248 log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir)
249 }
250 // The ≈ local-currency display's rate snapshot (fx.go) — only worth
251 // fetching where a price picker exists, i.e. where provisioning is wired.
252 if s.cloud != nil {
253 go s.fxLoop(envOr("LCHAT_HOME_FX_URL", ecbRatesURL))
254 }
255 log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin)
256 if err := serve(*addr, s.handler()); err != nil {
257 log.Fatal(err)
258 }
259}
260
261// openStore selects the vaultStore backend by LCHAT_HOME_STORE. Unset (or
262// "sqlite") keeps today's zero-config self-hoster default; "s3" is the
263// flagship-deployment backend (#550): LCHAT_HOME_S3_BUCKET + _REGION required,
264// _PREFIX and _ENDPOINT optional, credentials from the standard AWS env.
265// Misconfiguration fails fast — an s3-selected deployment must never limp
266// along on an accidental empty local DB.
267func openStore(dbPath string) (vaultStore, string, error) {
268 switch backend := os.Getenv("LCHAT_HOME_STORE"); backend {
269 case "", "sqlite":
270 st, err := openSQLiteStore(dbPath)
271 return st, "sqlite " + dbPath, err
272 case "s3":
273 bucket, region := os.Getenv("LCHAT_HOME_S3_BUCKET"), os.Getenv("LCHAT_HOME_S3_REGION")
274 st, err := newS3Store(bucket, region, os.Getenv("LCHAT_HOME_S3_PREFIX"), os.Getenv("LCHAT_HOME_S3_ENDPOINT"))
275 if err != nil {
276 return nil, "", fmt.Errorf("LCHAT_HOME_STORE=s3: %w (need LCHAT_HOME_S3_BUCKET, LCHAT_HOME_S3_REGION, AWS credentials in env)", err)
277 }
278 return st, fmt.Sprintf("s3 bucket %s (%s)", bucket, region), nil
279 default:
280 return nil, "", fmt.Errorf("unknown LCHAT_HOME_STORE %q (want \"sqlite\" or \"s3\")", backend)
281 }
282}
283
284// serve listens on addr and serves h. A path-like addr (starts with "/") is a
285// unix socket — how the instance router fronts this service, reverse-proxying to
286// a socket rather than a TCP port; anything else is a TCP address (the default,
287// e.g. on a container platform that health-checks over HTTP). On the socket path
288// it clobbers only a stale socket (nothing listening) and locks it to 0600,
289// mirroring the main binary's listen().
290func serve(addr string, h http.Handler) error {
291 // ReadHeaderTimeout caps how long a client may dribble request headers
292 // (Slowloris): the TCP branch binds straight to public TCP on a container
293 // platform. Same 10s the messenger's own listeners use (main.go).
294 srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
295 if !strings.HasPrefix(addr, "/") {
296 srv.Addr = addr
297 return srv.ListenAndServe()
298 }
299 if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil {
300 c.Close()
301 return fmt.Errorf("socket %s is already in use by another process", addr)
302 }
303 if err := os.Remove(addr); err != nil && !os.IsNotExist(err) {
304 return fmt.Errorf("remove stale socket %s: %w", addr, err)
305 }
306 ln, err := net.Listen("unix", addr)
307 if err != nil {
308 return err
309 }
310 defer ln.Close()
311 if err := os.Chmod(addr, 0o600); err != nil {
312 return fmt.Errorf("chmod socket %s: %w", addr, err)
313 }
314 return srv.Serve(ln)
315}
316
317// newServer builds the server. stateSecret keys the stateless ceremony/session
318// tokens; empty means "ephemeral random key" (fine single-process — main logs
319// the caveat).
320func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, error) {
321 u, err := url.Parse(origin)
322 if err != nil {
323 return nil, err
324 }
325 wa, err := newRelyingParty(u.Hostname(), origin)
326 if err != nil {
327 return nil, err
328 }
329 keys, err := newStateKeys(stateSecret)
330 if err != nil {
331 return nil, err
332 }
333 page, err := webFS.ReadFile("web/pages/index.html")
334 if err != nil {
335 return nil, err
336 }
337 // The GitHub client id is per-instance (separate OAuth apps per home),
338 // so it rides the page as a meta tag; empty env leaves the door dark.
339 page = bytes.ReplaceAll(page, []byte("__GITHUB_CLIENT_ID__"),
340 []byte(os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID")))
341 enrollPage, err := webFS.ReadFile("web/pages/enroll.html")
342 if err != nil {
343 return nil, err
344 }
345 restorePage, err := webFS.ReadFile("web/pages/restore.html")
346 if err != nil {
347 return nil, err
348 }
349 return &Server{
350 store: store,
351 enrollPage: enrollPage,
352 restorePage: restorePage,
353 wa: wa,
354 keys: keys,
355 limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
356 page: page,
357 pageHashes: inlineScriptHashes(page),
358 origin: strings.TrimRight(origin, "/"),
359 enrol: newEnrolMarks(),
360 }, nil
361}
362
363// inlineScriptHashes renders 'sha256-…' CSP sources for every inline
364// <script> on a page. The landing page's pre-paint boot gate is inline BY
365// NECESSITY (it must run before first paint), and a hash allowance computed
366// from the served bytes can never drift from an edited script — the previous
367// hardcoded "no inline scripts" CSP silently discarded the gate for months,
368// which surfaced as an unfixable-looking sign-in flash (2026-07-18).
369func inlineScriptHashes(page []byte) string {
370 var out strings.Builder
371 rest := page
372 for {
373 i := bytes.Index(rest, []byte("<script>"))
374 if i < 0 {
375 break
376 }
377 rest = rest[i+len("<script>"):]
378 j := bytes.Index(rest, []byte("</script>"))
379 if j < 0 {
380 break
381 }
382 sum := sha256.Sum256(rest[:j])
383 out.WriteString(" 'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'")
384 rest = rest[j:]
385 }
386 return out.String()
387}
388
389// handler wires up all routes. It's a method so tests can exercise the full
390// router via httptest without starting a real listener.
391func (s *Server) handler() http.Handler {
392 staticFS, err := fs.Sub(webFS, "web/static")
393 if err != nil {
394 log.Fatalf("static fs: %v", err)
395 }
396
397 mux := http.NewServeMux()
398 mux.HandleFunc("GET /", s.servePage)
399 // The enrolment popup: an instance opens this to add itself to the backup.
400 // The server-entry payload rides in the URL fragment (never reaches here);
401 // this just serves the page.
402 mux.HandleFunc("GET /enroll", s.serveEnroll)
403 // The restore page: a signed-out instance opens this to sign back in from the
404 // backup. Like /enroll, the request rides in the fragment; this just serves it.
405 mux.HandleFunc("GET /restore", s.serveRestore)
406 mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))))
407
408 // The planet-scale operations guide — optional (see the infrastructureDir
409 // doc comment); s.infrastructureDir == "" answers 404, checked at request
410 // time like every other optional feature here (s.cloud, s.pow). Read from
411 // disk so the guide's source stays exactly where it lives in the repo
412 // (docs/planet-scale-guide/), with nothing duplicated or re-embedded
413 // here. Public, unauthenticated, and read-only — the guide itself is
414 // careful to name no real hostname, IP, or box identity (see its own
415 // PRIVACY convention), so serving it alongside the home page adds no
416 // exposure beyond the guide's own content.
417 mux.HandleFunc("GET /infrastructure/", s.serveInfrastructure)
418 mux.HandleFunc("GET /infrastructure", func(w http.ResponseWriter, r *http.Request) {
419 if s.infrastructureDir == "" {
420 http.NotFound(w, r)
421 return
422 }
423 http.Redirect(w, r, "/infrastructure/", http.StatusMovedPermanently)
424 })
425
426 // Apple app-site-association: lets the native Eleven apps run passkey
427 // ceremonies against this RP (the webcredentials service). Content-blind —
428 // it names only the apps' team+bundle ids, nothing user-specific.
429 mux.HandleFunc("GET /.well-known/apple-app-site-association", s.serveAASA)
430
431 // WebAuthn ceremonies. begin endpoints are rate-limited: they're the open,
432 // anonymous surface (finish requires a signed response, which is its own gate).
433 mux.HandleFunc("POST /api/register/begin", s.rateLimited(s.registerBegin))
434 mux.HandleFunc("POST /api/register/finish", s.registerFinish)
435 mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin))
436 mux.HandleFunc("POST /api/login/finish", s.loginFinish)
437 // Sign in with Apple (siwa.go): the one verifier every platform posts
438 // Apple's identity token to, and the web flow's form_post trampoline.
439 mux.HandleFunc("POST /api/siwa/verify", s.rateLimited(s.siwaVerify))
440 mux.HandleFunc("POST /siwa/return", s.rateLimited(s.siwaReturn))
441 // The generalized OIDC doors (oidc.go): per-provider verify on the same
442 // one-verifier contract as /api/siwa/verify, and the web flow's form_post
443 // trampoline mirroring /siwa/return. Apple keeps its dedicated routes.
444 mux.HandleFunc("POST /api/oidc/{provider}/verify", s.rateLimited(s.oidcVerify))
445 mux.HandleFunc("POST /oidc/return", s.rateLimited(s.oidcReturn))
446 // GitHub (the code-flow door): a literal pattern wins over the
447 // {provider} wildcard above, and the GET return is the code-flow half
448 // of the trampoline (form_post keeps the POST half).
449 mux.HandleFunc("POST /api/oidc/github/verify", s.rateLimited(s.githubVerify))
450 mux.HandleFunc("GET /oidc/return", s.rateLimited(s.oauthCodeReturn))
451 // The email anchor (email.go): request a sign-in code, redeem it.
452 mux.HandleFunc("POST /api/email/request", s.rateLimited(s.emailRequest))
453 mux.HandleFunc("POST /api/email/verify", s.rateLimited(s.emailVerify))
454
455 // Enrolment completion oracle (enrolstatus.go): /enroll reports a finished
456 // ceremony; the instance page that launched it polls for the answer. The
457 // status read is CORS-open and unlimited on purpose — it's a map lookup
458 // gated by an unguessable client nonce, and the poller may not share an IP
459 // budget with anyone (the write side is rate-limited like the begins).
460 mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler))
461 mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler)
462 // The public price book (pricebook.go): read by SPACE origins' browser JS
463 // (the setup conversation's plan step, #1374) — no auth, CORS-open.
464 mux.HandleFunc("GET /api/pricebook", s.pricebookHandler)
465
466 // The blob: read/write with a link token; delete only with a fresh ceremony.
467 mux.HandleFunc("GET /api/blob", s.getBlobHandler)
468 mux.HandleFunc("PUT /api/blob", s.putBlobHandler)
469 mux.HandleFunc("DELETE /api/blob", s.deleteBlobHandler)
470
471 // Space provisioning (optional: 501 unless a cloud provisioner is
472 // configured). Link-token auth + rate limit; the provisioner adds a quota.
473 // /api/features lets the page hide the "create a space" UI when this home
474 // server doesn't offer it (public — it reveals only whether the feature is on).
475 mux.HandleFunc("GET /api/features", s.featuresHandler)
476 mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler))
477 // Pay-before-create (provision.go): mint the Checkout for a paid space,
478 // then claim it into existence once Stripe confirms. Both rate-limited —
479 // one mints sessions, the other dials Stripe + can provision.
480 mux.HandleFunc("POST /api/spaces/checkout-create", s.rateLimited(s.checkoutCreateHandler))
481 mux.HandleFunc("POST /api/spaces/claim-create", s.rateLimited(s.claimCreateHandler))
482 // The proof-of-work challenge behind creation (cheap to mint; rate-limited
483 // with the same budget as the creates it gates).
484 mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler))
485 // Demo spaces (demo.go): the account-free try-it path. Deliberately
486 // unauthenticated — the demo gates (mandatory adaptive proof of work +
487 // per-connection cooldown) are the whole boundary, plus the shared
488 // per-IP rate limiter in front.
489 mux.HandleFunc("POST /api/demo/challenge", s.rateLimited(s.demoChallengeHandler))
490 mux.HandleFunc("POST /api/demo", s.rateLimited(s.demoCreateHandler))
491 // Claiming a demo as your free space — additive, so the link token
492 // suffices (same reasoning as creation).
493 mux.HandleFunc("POST /api/spaces/claim", s.rateLimited(s.claimSpaceHandler))
494 // The countdown banner's live "still a demo?" read — public + CORS-open
495 // (it renders on the demo space's origin, pre-account); discloses only
496 // demo-ness + expiry, never existence or ownership. Deliberately OUTSIDE
497 // the shared ceremony limiter: this read auto-fires on every demo-tab load
498 // and refocus, and behind the router every caller shares one RemoteAddr
499 // bucket — it would drain the tokens sign-in ceremonies need. A short
500 // per-host cache bounds the load instead (demoStatusCache).
501 mux.HandleFunc("GET /api/spaces/demo-status", s.demoStatusHandler)
502 // Which spaces this user provisioned (read; drives the delete affordance)…
503 mux.HandleFunc("GET /api/spaces", s.listSpacesHandler)
504 // …and permanent deletion of one — ceremony-gated like every destructive op.
505 mux.HandleFunc("DELETE /api/spaces/{host}", s.deleteSpaceHandler)
506 // Upgrading a space (a Stripe Checkout round trip) is additive, so it takes
507 // the link token; rate-limited because each call mints a checkout session.
508 mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler))
509 // The picker's ≈ local-currency rates (fx.go) — public, cached, tiny.
510 mux.HandleFunc("GET /api/fx", s.fxHandler)
511 // Managing an existing subscription (Stripe's hosted Billing Portal).
512 mux.HandleFunc("POST /api/spaces/{host}/portal", s.rateLimited(s.portalSpaceHandler))
513
514 // Devices: list with a link token; revoke only with a fresh ceremony. The
515 // asymmetry is deliberate — a stolen link token must never reduce access.
516 mux.HandleFunc("GET /api/devices", s.listDevicesHandler)
517 mux.HandleFunc("DELETE /api/devices/{id}", s.deleteDeviceHandler)
518
519 // Credentials: removing a passkey reduces who can ever unlock the blob, so
520 // it's ceremony-gated like the other destructive ops.
521 mux.HandleFunc("DELETE /api/credentials/{id}", s.deleteCredentialHandler)
522
523 // Sign-in anchors (anchors.go): every way into the account as one list —
524 // read with a link token, remove with a fresh ceremony. The settings
525 // surface (#1098) builds on these.
526 mux.HandleFunc("GET /api/anchors", s.listAnchorsHandler)
527 mux.HandleFunc("DELETE /api/anchors/{kind}/{id}", s.deleteAnchorHandler)
528
529 // Account merge (merge.go, #1098): both-sides-proofs-gated. The client
530 // unions the vaults and empties the absorbed one FIRST; this then moves
531 // anchors/passkeys/devices and retires the emptied account.
532 mux.HandleFunc("POST /api/account/merge", s.mergeAccountHandler)
533
534 return securityHeaders(mux)
535}
536
537// securityHeaders adds defense-in-depth headers to every response (mirrors the
538// messenger's). The blob download gets nosniff like any other response here.
539func securityHeaders(next http.Handler) http.Handler {
540 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
541 h := w.Header()
542 h.Set("X-Content-Type-Options", "nosniff")
543 h.Set("X-Frame-Options", "DENY")
544 h.Set("Referrer-Policy", "no-referrer")
545 h.Set("Strict-Transport-Security", "max-age=31536000")
546 next.ServeHTTP(w, r)
547 })
548}
549
550func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
551 // "GET /" matches everything; only the exact root serves the page.
552 if r.URL.Path != "/" {
553 http.NotFound(w, r)
554 return
555 }
556 // The page's one inline script — the pre-paint boot gate — is allowed by
557 // its content hash (pageHashes, computed from the served bytes at boot).
558 // connect-src admits https: beyond 'self' for exactly one client-side
559 // move: the last-admin check asks a saved space's own /api/me before
560 // allowing "Delete your access" (home.js checkSoleAdmin). Space hosts are
561 // dynamic, so they can't be allowlisted by name — and the fetch happening
562 // in the browser is the point: this server stays blind to which spaces a
563 // user holds.
564 w.Header().Set("Content-Security-Policy",
565 "default-src 'self'; script-src 'self'"+s.pageHashes+"; style-src 'self' 'unsafe-inline'; "+
566 "img-src 'self' data:; connect-src 'self' https:; "+
567 "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
568 w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
569 w.Header().Set("Content-Type", "text/html; charset=utf-8")
570 w.Write(s.page)
571}
572
573// serveAASA publishes the Apple app-site-association so the native Eleven apps
574// can bind passkeys to this relying party (the `webcredentials` service). Only
575// team+bundle ids appear — nothing user-identifying, consistent with this
576// server's content-blind posture. Served at the exact well-known path Apple
577// fetches, as application/json with no auth.
578func (s *Server) serveAASA(w http.ResponseWriter, r *http.Request) {
579 w.Header().Set("Content-Type", "application/json")
580 w.Write([]byte(`{"webcredentials":{"apps":[` +
581 `"J5NDF5PCD8.com.elevenmessenger.messenger",` +
582 `"J5NDF5PCD8.com.elevenmessenger.messenger.mac"]}}`))
583}
584
585// serveEnroll serves the enrolment popup. Same CSP as the main page — no inline
586// scripts, no cross-origin fetches; the only cross-origin move is a postMessage
587// back to the opener, which CSP doesn't govern.
588func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) {
589 w.Header().Set("Content-Security-Policy",
590 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
591 "img-src 'self' data:; connect-src 'self'; "+
592 "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
593 w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
594 w.Header().Set("Content-Type", "text/html; charset=utf-8")
595 w.Write(s.enrollPage)
596}
597
598// serveRestore serves the restore page (same CSP as the others).
599func (s *Server) serveRestore(w http.ResponseWriter, r *http.Request) {
600 w.Header().Set("Content-Security-Policy",
601 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
602 "img-src 'self' data:; connect-src 'self'; "+
603 "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
604 w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
605 w.Header().Set("Content-Type", "text/html; charset=utf-8")
606 w.Write(s.restorePage)
607}
608
609func noCache(next http.Handler) http.Handler {
610 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
611 w.Header().Set("Cache-Control", "no-cache")
612 next.ServeHTTP(w, r)
613 })
614}
615
616// serveInfrastructure answers /infrastructure/* from an on-disk directory
617// (infrastructureDir), or 404 if unconfigured. A strict, same-origin-only
618// CSP: the guide's pages carry no inline scripts/styles and make no outside
619// requests (unlike "/", which allows connect-src https: for one client-side
620// admin check), so this can be tighter than the rest of the site. Never
621// cached — like the embedded pages, the guide must never outlive a deploy.
622func (s *Server) serveInfrastructure(w http.ResponseWriter, r *http.Request) {
623 if s.infrastructureDir == "" {
624 http.NotFound(w, r)
625 return
626 }
627 w.Header().Set("Cache-Control", "no-cache")
628 w.Header().Set("Content-Security-Policy",
629 "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; "+
630 "object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
631 http.StripPrefix("/infrastructure/", http.FileServer(http.Dir(s.infrastructureDir))).ServeHTTP(w, r)
632}
633
634// ---- auth helpers ---------------------------------------------------------
635
636// bearerToken pulls the token out of "Authorization: Bearer <t>".
637func bearerToken(r *http.Request) string {
638 h := r.Header.Get("Authorization")
639 if strings.HasPrefix(h, "Bearer ") {
640 return strings.TrimPrefix(h, "Bearer ")
641 }
642 return ""
643}
644
645// linkUser authenticates a request by device link token; ""/false when absent
646// or unknown. Hashing happens here — the store is keyed by hash and never sees
647// a presented plaintext.
648func (s *Server) linkUser(r *http.Request) (string, bool) {
649 tok := bearerToken(r)
650 if tok == "" {
651 return "", false
652 }
653 return s.store.linkTokenUser(hashToken(tok))
654}
655
656// ceremonyUser authenticates a request by fresh ceremony proof (the
657// X-Ceremony-Proof header, minted by login/register finish within
658// ceremonyWindow). Required for destructive ops — never accept a link token.
659// Verification is stateless: the proof carries its uid + expiry under an HMAC
660// (stateless.go), so nothing is looked up.
661func (s *Server) ceremonyUser(r *http.Request) (string, bool) {
662 proof := r.Header.Get("X-Ceremony-Proof")
663 if proof == "" {
664 return "", false
665 }
666 return s.keys.ceremonyUser(proof)
667}
668
669// ---- blob -------------------------------------------------------------------
670
671func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) {
672 uid, ok := s.linkUser(r)
673 if !ok {
674 http.Error(w, "unauthorized", http.StatusUnauthorized)
675 return
676 }
677 data, version, found, err := s.store.getBlob(uid)
678 if err != nil {
679 http.Error(w, "db error", http.StatusInternalServerError)
680 return
681 }
682 if !found {
683 http.Error(w, "no blob", http.StatusNotFound)
684 return
685 }
686 etag := `"` + version + `"`
687 w.Header().Set("ETag", etag)
688 if r.Header.Get("If-None-Match") == etag {
689 w.WriteHeader(http.StatusNotModified)
690 return
691 }
692 w.Header().Set("Content-Type", "application/octet-stream")
693 w.Write(data)
694}
695
696func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) {
697 uid, ok := s.linkUser(r)
698 if !ok {
699 http.Error(w, "unauthorized", http.StatusUnauthorized)
700 return
701 }
702 // If-Match is mandatory: `"0"` claims "no blob yet", anything else names the
703 // version being replaced. A mismatch means another device wrote first — the
704 // client re-fetches, merges its (small) list, and retries.
705 ifMatch := strings.Trim(r.Header.Get("If-Match"), `"`)
706 if ifMatch == "" {
707 http.Error(w, "If-Match required", http.StatusPreconditionRequired)
708 return
709 }
710 data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes))
711 if err != nil {
712 http.Error(w, "blob too large", http.StatusRequestEntityTooLarge)
713 return
714 }
715 if len(data) == 0 {
716 http.Error(w, "empty blob", http.StatusBadRequest)
717 return
718 }
719 newVersion, conflict, err := s.store.putBlob(uid, data, ifMatch)
720 if errors.Is(err, errBadVersion) {
721 http.Error(w, "bad If-Match", http.StatusBadRequest)
722 return
723 }
724 if err != nil {
725 http.Error(w, "db error", http.StatusInternalServerError)
726 return
727 }
728 if conflict {
729 w.Header().Set("ETag", `"`+newVersion+`"`)
730 http.Error(w, "version conflict", http.StatusConflict)
731 return
732 }
733 w.Header().Set("ETag", `"`+newVersion+`"`)
734 // The version is an opaque string on the wire, like the ETag — clients
735 // already treat it that way (#550 Phase 0), so a future backend's
736 // non-numeric versions need no client change.
737 writeJSON(w, http.StatusOK, map[string]any{"version": newVersion})
738}
739
740func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) {
741 uid, ok := s.ceremonyUser(r)
742 if !ok {
743 http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
744 return
745 }
746 if err := s.store.deleteBlob(uid); err != nil {
747 http.Error(w, "db error", http.StatusInternalServerError)
748 return
749 }
750 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
751}
752
753// ---- devices ----------------------------------------------------------------
754
755func (s *Server) listDevicesHandler(w http.ResponseWriter, r *http.Request) {
756 uid, ok := s.linkUser(r)
757 if !ok {
758 http.Error(w, "unauthorized", http.StatusUnauthorized)
759 return
760 }
761 devices, err := s.store.listDevices(uid)
762 if err != nil {
763 http.Error(w, "db error", http.StatusInternalServerError)
764 return
765 }
766 current := s.store.linkTokenID(hashToken(bearerToken(r)))
767 for i := range devices {
768 devices[i].Current = devices[i].ID == current
769 }
770 writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
771}
772
773func (s *Server) deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
774 uid, ok := s.ceremonyUser(r)
775 if !ok {
776 http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
777 return
778 }
779 deleted, err := s.store.deleteDevice(uid, r.PathValue("id"))
780 if err != nil {
781 http.Error(w, "db error", http.StatusInternalServerError)
782 return
783 }
784 if !deleted {
785 http.Error(w, "no such device", http.StatusNotFound)
786 return
787 }
788 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
789}
790
791// ---- credentials -----------------------------------------------------------------
792
793func (s *Server) deleteCredentialHandler(w http.ResponseWriter, r *http.Request) {
794 uid, ok := s.ceremonyUser(r)
795 if !ok {
796 http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
797 return
798 }
799 deleted, last, err := s.store.deleteCredential(uid, r.PathValue("id"))
800 if err != nil {
801 http.Error(w, "db error", http.StatusInternalServerError)
802 return
803 }
804 if last {
805 // Removing the account's LAST sign-in method would brick it: the blob
806 // stays live with no way to ever get in again. This used to be a
807 // last-PASSKEY guard; under the anchor model (#1089) the store counts
808 // SiwA and email anchors too, so an account holding another anchor CAN
809 // shed its final passkey. Winding an account down is the
810 // (ceremony-gated) blob delete, explicitly.
811 http.Error(w, "cannot remove your last way to sign in — delete the backup instead", http.StatusConflict)
812 return
813 }
814 if !deleted {
815 http.Error(w, "no such credential", http.StatusNotFound)
816 return
817 }
818 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
819}
820
821// ---- rate limiting ------------------------------------------------------------
822
823// rateLimiter is a per-key (IP) token bucket. Buckets refill continuously and
824// idle ones are swept when the map grows, so memory stays bounded.
825type rateLimiter struct {
826 mu sync.Mutex
827 buckets map[string]*bucket
828 burst float64
829 perSec float64
830}
831
832type bucket struct {
833 tokens float64
834 last time.Time
835}
836
837func newRateLimiter(burst, perSec float64) *rateLimiter {
838 return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec}
839}
840
841func (rl *rateLimiter) allow(key string) bool {
842 rl.mu.Lock()
843 defer rl.mu.Unlock()
844 now := time.Now()
845 if len(rl.buckets) > 10000 {
846 for k, b := range rl.buckets {
847 if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour {
848 delete(rl.buckets, k)
849 }
850 }
851 }
852 b, ok := rl.buckets[key]
853 if !ok {
854 b = &bucket{tokens: rl.burst, last: now}
855 rl.buckets[key] = b
856 }
857 b.tokens += now.Sub(b.last).Seconds() * rl.perSec
858 if b.tokens > rl.burst {
859 b.tokens = rl.burst
860 }
861 b.last = now
862 if b.tokens < 1 {
863 return false
864 }
865 b.tokens--
866 return true
867}
868
869func (s *Server) rateLimited(next http.HandlerFunc) http.HandlerFunc {
870 return func(w http.ResponseWriter, r *http.Request) {
871 ip, _, err := net.SplitHostPort(r.RemoteAddr)
872 if err != nil {
873 ip = r.RemoteAddr
874 }
875 if !s.limiter.allow(ip) {
876 http.Error(w, "rate limited", http.StatusTooManyRequests)
877 return
878 }
879 next(w, r)
880 }
881}
882
883// ---- small helpers -------------------------------------------------------------
884
885func writeJSON(w http.ResponseWriter, status int, v any) {
886 w.Header().Set("Content-Type", "application/json")
887 w.WriteHeader(status)
888 if err := json.NewEncoder(w).Encode(v); err != nil {
889 log.Printf("write json: %v", err)
890 }
891}
892
893func envOr(key, def string) string {
894 if v := os.Getenv(key); v != "" {
895 return v
896 }
897 return def
898}
899