// The Eleven home server: a tiny, anonymous, content-blind, passkey-gated blob // store (docs/onboarding-and-home-server.md). It holds, per user: WebAuthn // credential public keys, ONE opaque client-encrypted blob (padded client-side // so length leaks nothing), and hashed device link tokens. No email, no // username, no display name — the credential is the identity. It is strictly // additive to the messenger: clients with no enrolment never contact it. package main import ( "bytes" "crypto/sha256" "embed" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" ) // maxBlobBytes caps a blob upload. The client pads the plaintext to a fixed // 16 KiB before encrypting (see web/static/crypto.js), so every blob's // ciphertext is the same size; the extra headroom here is the envelope header // (one wrapped DEK per enrolled passkey) plus AES-GCM overhead. The cap is a // hard abuse control, not a privacy boundary — sizes below it are already // uniform by construction. const maxBlobBytes = 18 << 10 //go:embed web/pages/*.html web/static/* var webFS embed.FS type Server struct { // store is the persistence seam (vaultStore, store.go) — SQLite today, an // object store later; handlers never know which. store vaultStore wa *waRelyingParty // keys mints/verifies home's stateless auth tokens (stateless.go): HMAC // ceremony proofs and AES-GCM-sealed in-flight WebAuthn sessions. Nothing // per-ceremony is held in this process — any replica can finish what // another began. keys *stateKeys // limiter rate-limits the ceremony begin endpoints per IP — the abuse // surface of an open, anonymous registration endpoint. limiter *rateLimiter // Sign in with Apple (siwa.go): the accepted token audiences (iOS bundle // id + web Services ID), the account-keying pepper, and Apple's JWKS // cache. Both empty/nil → the endpoints answer 404 (feature off). siwaClientIDs []string siwaPepper []byte siwa *jwksCache // oidc is the generalized provider table (oidc.go) — the Google and // Microsoft doors, keyed by route name. Each row gates like SiwA (client // ids + pepper, both or neither); empty/nil → every /api/oidc/* and // /oidc/return answers 404. Apple stays on the dedicated fields above. oidc map[string]*oidcProvider github *githubDoor // the code-exchange door (github.go); nil = dark // 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 // provisioner, so demo and every host-agnostic fallback keep working. cloud *cloudProvisioner // regions, when LCHAT_CLOUD_REGIONS names more than one provisioner, lets a // signed-in user's new space be placed per country (docs/dev-mesh.md). Empty // means single-region: s.cloud is the only provisioner. defaultRegion is the // region code a create with no explicit choice lands in. regions []cloudRegion defaultRegion string // pow gates space creation behind hashcash when LCHAT_POW_BITS > 0 // (pow.go); nil = no gate. pow *powGate // demo is the account-free demo-space surface and its abuse gates // (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset). demo *demoGate // fx caches the ECB reference rates behind the picker's ≈ display // (fx.go) — zero value ready (empty until the loop's first fetch). fx fxCache // pricebook caches the provisioner's public price book for the // unauthenticated /api/pricebook (pricebook.go) — zero value ready. pricebook pricebookCache // demoStatus caches the countdown banner's live status reads (demo.go) — // zero value ready. demoStatus demoStatusCache // origin is this server's public origin (also the WebAuthn RP origin) — // used to build the checkout return URLs. origin string page []byte pageHashes string // 'sha256-…' CSP sources for the page's inline scripts enrollPage []byte restorePage []byte // enrol remembers recently-completed enrolments by nonce (enrolstatus.go) // so the page that launched one can poll for the finish. In-memory: if this // server ever runs replicated, move the marks into the store. enrol *enrolMarks // infrastructureDir is an optional on-disk directory serving the // planet-scale operations guide (docs/planet-scale-guide/ in the repo) at // /infrastructure/ — read from disk, not embedded: go:embed can't reach // outside this package's own directory tree without duplicating the // guide's source, so a deploy step copies it here instead (see // docs/planet-scale-guide's own deploy note). Empty means the route is // off entirely — most self-hosted deployments have no reason to publish it. infrastructureDir string } func main() { addr := flag.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)") dbPath := flag.String("db", envOr("LCHAT_HOME_DB", "home.db"), "path to SQLite database file (env: LCHAT_HOME_DB)") origin := flag.String("origin", envOr("LCHAT_HOME_ORIGIN", "http://localhost:1748"), "public origin, e.g. https://home.elevenmessenger.com (env: LCHAT_HOME_ORIGIN) — WebAuthn RP identity") cloudURL := flag.String("cloud-url", os.Getenv("LCHAT_CLOUD_URL"), "space provisioner base URL, e.g. https://cloud.elevenmessenger.com (env: LCHAT_CLOUD_URL); empty disables space creation") cloudToken := flag.String("cloud-token", os.Getenv("LCHAT_CLOUD_TOKEN"), "caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)") flag.Parse() store, storeDesc, err := openStore(*dbPath) if err != nil { log.Fatalf("open store: %v", err) } // LCHAT_HOME_SESSION_KEY signs/seals home's stateless ceremony proofs and // in-flight WebAuthn sessions (stateless.go). It is auth plumbing, NOT a // decryption key for any vault. Any long random string works (e.g. // `openssl rand -base64 32`); it only needs setting when several replicas // must honour each other's tokens. Env-only, deliberately not a flag — // secrets don't belong in process listings. secret := []byte(os.Getenv("LCHAT_HOME_SESSION_KEY")) if len(secret) == 0 { 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") } s, err := newServer(store, *origin, secret) if err != nil { log.Fatalf("server: %v", err) } // Sign in with Apple: LCHAT_HOME_SIWA_CLIENT_IDS (comma-separated token // audiences — the iOS bundle id and the web Services ID) turns the // feature on, and LCHAT_HOME_SIWA_PEPPER keys accounts as // HMAC(pepper, sub). The pepper is env-only (a secret) and, unlike the // session key, must be DURABLE — it names accounts, so an ephemeral // fallback would orphan every SiwA account on restart. Both or neither. siwaClients, siwaPepper := os.Getenv("LCHAT_HOME_SIWA_CLIENT_IDS"), os.Getenv("LCHAT_HOME_SIWA_PEPPER") if (siwaClients == "") != (siwaPepper == "") { log.Fatalf("Sign in with Apple needs BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER (or neither)") } if siwaClients != "" { for _, c := range strings.Split(siwaClients, ",") { if c = strings.TrimSpace(c); c != "" { s.siwaClientIDs = append(s.siwaClientIDs, c) } } s.siwaPepper = []byte(siwaPepper) 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) } // The OIDC provider table (oidc.go): each door rides the SiwA pattern — // LCHAT_HOME_

_CLIENT_IDS (comma-separated token audiences) turns it on // and LCHAT_HOME_

_PEPPER keys its accounts as HMAC(pepper, sub). The // peppers are per provider, env-only, and DURABLE (they name accounts; no // ephemeral fallback). Both or neither, per provider. Apple keeps its own // LCHAT_HOME_SIWA_* pair above. for _, pc := range []struct{ name, idsEnv, pepperEnv string }{ {"google", "LCHAT_HOME_GOOGLE_CLIENT_IDS", "LCHAT_HOME_GOOGLE_PEPPER"}, {"microsoft", "LCHAT_HOME_MICROSOFT_CLIENT_IDS", "LCHAT_HOME_MICROSOFT_PEPPER"}, } { ids, pepper := os.Getenv(pc.idsEnv), os.Getenv(pc.pepperEnv) if (ids == "") != (pepper == "") { log.Fatalf("sign in with %s needs BOTH %s and %s (or neither)", pc.name, pc.idsEnv, pc.pepperEnv) } if ids == "" { continue } p := newOIDCProvider(pc.name, splitCommaList(ids), []byte(pepper)) if s.oidc == nil { s.oidc = map[string]*oidcProvider{} } s.oidc[pc.name] = p log.Printf("sign in with %s on (%d client ids)", p.display, len(p.clientIDs)) } // GitHub is the one code-flow door (github.go): a single client id + // SECRET per instance (the prod and dev homes are separate GitHub OAuth // apps), plus the usual durable pepper. All three or none. ghID, ghSecret, ghPepper := os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID"), os.Getenv("LCHAT_HOME_GITHUB_CLIENT_SECRET"), os.Getenv("LCHAT_HOME_GITHUB_PEPPER") if (ghID == "") != (ghSecret == "") || (ghID == "") != (ghPepper == "") { log.Fatal("sign in with GitHub needs LCHAT_HOME_GITHUB_CLIENT_ID, _CLIENT_SECRET, and _PEPPER together (or none)") } if ghID != "" { s.github = &githubDoor{clientID: ghID, secret: ghSecret, pepper: []byte(ghPepper)} log.Printf("sign in with GitHub on") } if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { log.Printf("space provisioning via %s", s.cloud.url) } // Per-country provisioning: LCHAT_CLOUD_REGIONS (a JSON array of // {code,name,url,token}) plus LCHAT_CLOUD_DEFAULT_REGION. When set it // supersedes the single LCHAT_CLOUD_URL — s.cloud becomes the default // region's provisioner so demo and fallbacks are unchanged. if regions, defaultCode, err := parseCloudRegions(os.Getenv("LCHAT_CLOUD_REGIONS"), os.Getenv("LCHAT_CLOUD_DEFAULT_REGION")); err != nil { log.Fatalf("cloud regions: %v", err) } else if len(regions) > 0 { s.regions, s.defaultRegion = regions, defaultCode s.cloud = s.cloudForRegion(defaultCode) log.Printf("space provisioning across %d regions (default %s)", len(regions), defaultCode) } if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 { s.pow = newPowGate(bits, s.keys) log.Printf("space creation gated by proof of work (%d bits)", bits) } if os.Getenv("LCHAT_HOME_DEMO") == "1" { bits, _ := strconv.Atoi(envOr("LCHAT_DEMO_POW_BITS", "18")) gate, err := newDemoGate(bits, s.keys) if err != nil { log.Fatalf("demo gate: %v", err) } s.demo = gate log.Printf("demo spaces on (mandatory proof of work, base %d bits, adaptive)", bits) } if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" { log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir) } // The ≈ local-currency display's rate snapshot (fx.go) — only worth // fetching where a price picker exists, i.e. where provisioning is wired. if s.cloud != nil { go s.fxLoop(envOr("LCHAT_HOME_FX_URL", ecbRatesURL)) } log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin) if err := serve(*addr, s.handler()); err != nil { log.Fatal(err) } } // openStore selects the vaultStore backend by LCHAT_HOME_STORE. Unset (or // "sqlite") keeps today's zero-config self-hoster default; "s3" is the // flagship-deployment backend (#550): LCHAT_HOME_S3_BUCKET + _REGION required, // _PREFIX and _ENDPOINT optional, credentials from the standard AWS env. // Misconfiguration fails fast — an s3-selected deployment must never limp // along on an accidental empty local DB. func openStore(dbPath string) (vaultStore, string, error) { switch backend := os.Getenv("LCHAT_HOME_STORE"); backend { case "", "sqlite": st, err := openSQLiteStore(dbPath) return st, "sqlite " + dbPath, err case "s3": bucket, region := os.Getenv("LCHAT_HOME_S3_BUCKET"), os.Getenv("LCHAT_HOME_S3_REGION") st, err := newS3Store(bucket, region, os.Getenv("LCHAT_HOME_S3_PREFIX"), os.Getenv("LCHAT_HOME_S3_ENDPOINT")) if err != nil { return nil, "", fmt.Errorf("LCHAT_HOME_STORE=s3: %w (need LCHAT_HOME_S3_BUCKET, LCHAT_HOME_S3_REGION, AWS credentials in env)", err) } return st, fmt.Sprintf("s3 bucket %s (%s)", bucket, region), nil default: return nil, "", fmt.Errorf("unknown LCHAT_HOME_STORE %q (want \"sqlite\" or \"s3\")", backend) } } // serve listens on addr and serves h. A path-like addr (starts with "/") is a // unix socket — how the instance router fronts this service, reverse-proxying to // a socket rather than a TCP port; anything else is a TCP address (the default, // e.g. on a container platform that health-checks over HTTP). On the socket path // it clobbers only a stale socket (nothing listening) and locks it to 0600, // mirroring the main binary's listen(). func serve(addr string, h http.Handler) error { // ReadHeaderTimeout caps how long a client may dribble request headers // (Slowloris): the TCP branch binds straight to public TCP on a container // platform. Same 10s the messenger's own listeners use (main.go). srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second} if !strings.HasPrefix(addr, "/") { srv.Addr = addr return srv.ListenAndServe() } if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil { c.Close() return fmt.Errorf("socket %s is already in use by another process", addr) } if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove stale socket %s: %w", addr, err) } ln, err := net.Listen("unix", addr) if err != nil { return err } defer ln.Close() if err := os.Chmod(addr, 0o600); err != nil { return fmt.Errorf("chmod socket %s: %w", addr, err) } return srv.Serve(ln) } // newServer builds the server. stateSecret keys the stateless ceremony/session // tokens; empty means "ephemeral random key" (fine single-process — main logs // the caveat). func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, error) { u, err := url.Parse(origin) if err != nil { return nil, err } wa, err := newRelyingParty(u.Hostname(), origin) if err != nil { return nil, err } keys, err := newStateKeys(stateSecret) if err != nil { return nil, err } page, err := webFS.ReadFile("web/pages/index.html") if err != nil { return nil, err } // The GitHub client id is per-instance (separate OAuth apps per home), // so it rides the page as a meta tag; empty env leaves the door dark. page = bytes.ReplaceAll(page, []byte("__GITHUB_CLIENT_ID__"), []byte(os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID"))) enrollPage, err := webFS.ReadFile("web/pages/enroll.html") if err != nil { return nil, err } restorePage, err := webFS.ReadFile("web/pages/restore.html") if err != nil { return nil, err } return &Server{ store: store, enrollPage: enrollPage, restorePage: restorePage, wa: wa, keys: keys, limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute page: page, pageHashes: inlineScriptHashes(page), origin: strings.TrimRight(origin, "/"), enrol: newEnrolMarks(), }, nil } // inlineScriptHashes renders 'sha256-…' CSP sources for every inline // ")) if j < 0 { break } sum := sha256.Sum256(rest[:j]) out.WriteString(" 'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'") rest = rest[j:] } return out.String() } // handler wires up all routes. It's a method so tests can exercise the full // router via httptest without starting a real listener. func (s *Server) handler() http.Handler { staticFS, err := fs.Sub(webFS, "web/static") if err != nil { log.Fatalf("static fs: %v", err) } mux := http.NewServeMux() mux.HandleFunc("GET /", s.servePage) // The enrolment popup: an instance opens this to add itself to the backup. // The server-entry payload rides in the URL fragment (never reaches here); // this just serves the page. mux.HandleFunc("GET /enroll", s.serveEnroll) // The restore page: a signed-out instance opens this to sign back in from the // backup. Like /enroll, the request rides in the fragment; this just serves it. mux.HandleFunc("GET /restore", s.serveRestore) mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))) // The planet-scale operations guide — optional (see the infrastructureDir // doc comment); s.infrastructureDir == "" answers 404, checked at request // time like every other optional feature here (s.cloud, s.pow). Read from // disk so the guide's source stays exactly where it lives in the repo // (docs/planet-scale-guide/), with nothing duplicated or re-embedded // here. Public, unauthenticated, and read-only — the guide itself is // careful to name no real hostname, IP, or box identity (see its own // PRIVACY convention), so serving it alongside the home page adds no // exposure beyond the guide's own content. mux.HandleFunc("GET /infrastructure/", s.serveInfrastructure) mux.HandleFunc("GET /infrastructure", func(w http.ResponseWriter, r *http.Request) { if s.infrastructureDir == "" { http.NotFound(w, r) return } http.Redirect(w, r, "/infrastructure/", http.StatusMovedPermanently) }) // Apple app-site-association: lets the native Eleven apps run passkey // ceremonies against this RP (the webcredentials service). Content-blind — // it names only the apps' team+bundle ids, nothing user-specific. mux.HandleFunc("GET /.well-known/apple-app-site-association", s.serveAASA) // WebAuthn ceremonies. begin endpoints are rate-limited: they're the open, // anonymous surface (finish requires a signed response, which is its own gate). mux.HandleFunc("POST /api/register/begin", s.rateLimited(s.registerBegin)) mux.HandleFunc("POST /api/register/finish", s.registerFinish) mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin)) mux.HandleFunc("POST /api/login/finish", s.loginFinish) // Sign in with Apple (siwa.go): the one verifier every platform posts // 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 generalized OIDC doors (oidc.go): per-provider verify on the same // one-verifier contract as /api/siwa/verify, and the web flow's form_post // trampoline mirroring /siwa/return. Apple keeps its dedicated routes. mux.HandleFunc("POST /api/oidc/{provider}/verify", s.rateLimited(s.oidcVerify)) mux.HandleFunc("POST /oidc/return", s.rateLimited(s.oidcReturn)) // GitHub (the code-flow door): a literal pattern wins over the // {provider} wildcard above, and the GET return is the code-flow half // of the trampoline (form_post keeps the POST half). mux.HandleFunc("POST /api/oidc/github/verify", s.rateLimited(s.githubVerify)) mux.HandleFunc("GET /oidc/return", s.rateLimited(s.oauthCodeReturn)) // 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 // status read is CORS-open and unlimited on purpose — it's a map lookup // gated by an unguessable client nonce, and the poller may not share an IP // budget with anyone (the write side is rate-limited like the begins). mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler)) mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler) // The public price book (pricebook.go): read by SPACE origins' browser JS // (the setup conversation's plan step, #1374) — no auth, CORS-open. mux.HandleFunc("GET /api/pricebook", s.pricebookHandler) // The blob: read/write with a link token; delete only with a fresh ceremony. mux.HandleFunc("GET /api/blob", s.getBlobHandler) mux.HandleFunc("PUT /api/blob", s.putBlobHandler) mux.HandleFunc("DELETE /api/blob", s.deleteBlobHandler) // Space provisioning (optional: 501 unless a cloud provisioner is // configured). Link-token auth + rate limit; the provisioner adds a quota. // /api/features lets the page hide the "create a space" UI when this home // server doesn't offer it (public — it reveals only whether the feature is on). mux.HandleFunc("GET /api/features", s.featuresHandler) mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler)) // Pay-before-create (provision.go): mint the Checkout for a paid space, // then claim it into existence once Stripe confirms. Both rate-limited — // one mints sessions, the other dials Stripe + can provision. mux.HandleFunc("POST /api/spaces/checkout-create", s.rateLimited(s.checkoutCreateHandler)) mux.HandleFunc("POST /api/spaces/claim-create", s.rateLimited(s.claimCreateHandler)) // The proof-of-work challenge behind creation (cheap to mint; rate-limited // with the same budget as the creates it gates). mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler)) // Demo spaces (demo.go): the account-free try-it path. Deliberately // unauthenticated — the demo gates (mandatory adaptive proof of work + // per-connection cooldown) are the whole boundary, plus the shared // per-IP rate limiter in front. mux.HandleFunc("POST /api/demo/challenge", s.rateLimited(s.demoChallengeHandler)) mux.HandleFunc("POST /api/demo", s.rateLimited(s.demoCreateHandler)) // Claiming a demo as your free space — additive, so the link token // suffices (same reasoning as creation). mux.HandleFunc("POST /api/spaces/claim", s.rateLimited(s.claimSpaceHandler)) // The countdown banner's live "still a demo?" read — public + CORS-open // (it renders on the demo space's origin, pre-account); discloses only // demo-ness + expiry, never existence or ownership. Deliberately OUTSIDE // the shared ceremony limiter: this read auto-fires on every demo-tab load // and refocus, and behind the router every caller shares one RemoteAddr // bucket — it would drain the tokens sign-in ceremonies need. A short // per-host cache bounds the load instead (demoStatusCache). mux.HandleFunc("GET /api/spaces/demo-status", s.demoStatusHandler) // Which spaces this user provisioned (read; drives the delete affordance)… mux.HandleFunc("GET /api/spaces", s.listSpacesHandler) // …and permanent deletion of one — ceremony-gated like every destructive op. mux.HandleFunc("DELETE /api/spaces/{host}", s.deleteSpaceHandler) // Upgrading a space (a Stripe Checkout round trip) is additive, so it takes // the link token; rate-limited because each call mints a checkout session. mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler)) // The picker's ≈ local-currency rates (fx.go) — public, cached, tiny. mux.HandleFunc("GET /api/fx", s.fxHandler) // Managing an existing subscription (Stripe's hosted Billing Portal). mux.HandleFunc("POST /api/spaces/{host}/portal", s.rateLimited(s.portalSpaceHandler)) // Devices: list with a link token; revoke only with a fresh ceremony. The // asymmetry is deliberate — a stolen link token must never reduce access. mux.HandleFunc("GET /api/devices", s.listDevicesHandler) mux.HandleFunc("DELETE /api/devices/{id}", s.deleteDeviceHandler) // Credentials: removing a passkey reduces who can ever unlock the blob, so // it's ceremony-gated like the other destructive ops. mux.HandleFunc("DELETE /api/credentials/{id}", s.deleteCredentialHandler) // Sign-in anchors (anchors.go): every way into the account as one list — // read with a link token, remove with a fresh ceremony. The settings // surface (#1098) builds on these. mux.HandleFunc("GET /api/anchors", s.listAnchorsHandler) mux.HandleFunc("DELETE /api/anchors/{kind}/{id}", s.deleteAnchorHandler) // Account merge (merge.go, #1098): both-sides-proofs-gated. The client // unions the vaults and empties the absorbed one FIRST; this then moves // anchors/passkeys/devices and retires the emptied account. mux.HandleFunc("POST /api/account/merge", s.mergeAccountHandler) return securityHeaders(mux) } // securityHeaders adds defense-in-depth headers to every response (mirrors the // messenger's). The blob download gets nosniff like any other response here. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Strict-Transport-Security", "max-age=31536000") next.ServeHTTP(w, r) }) } func (s *Server) servePage(w http.ResponseWriter, r *http.Request) { // "GET /" matches everything; only the exact root serves the page. if r.URL.Path != "/" { http.NotFound(w, r) return } // The page's one inline script — the pre-paint boot gate — is allowed by // its content hash (pageHashes, computed from the served bytes at boot). // connect-src admits https: beyond 'self' for exactly one client-side // move: the last-admin check asks a saved space's own /api/me before // allowing "Delete your access" (home.js checkSoleAdmin). Space hosts are // dynamic, so they can't be allowlisted by name — and the fetch happening // in the browser is the point: this server stays blind to which spaces a // user holds. w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'"+s.pageHashes+"; style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; connect-src 'self' https:; "+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(s.page) } // serveAASA publishes the Apple app-site-association so the native Eleven apps // can bind passkeys to this relying party (the `webcredentials` service). Only // team+bundle ids appear — nothing user-identifying, consistent with this // server's content-blind posture. Served at the exact well-known path Apple // fetches, as application/json with no auth. func (s *Server) serveAASA(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"webcredentials":{"apps":[` + `"J5NDF5PCD8.com.elevenmessenger.messenger",` + `"J5NDF5PCD8.com.elevenmessenger.messenger.mac"]}}`)) } // serveEnroll serves the enrolment popup. Same CSP as the main page — no inline // scripts, no cross-origin fetches; the only cross-origin move is a postMessage // back to the opener, which CSP doesn't govern. func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; connect-src 'self'; "+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(s.enrollPage) } // serveRestore serves the restore page (same CSP as the others). func (s *Server) serveRestore(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; connect-src 'self'; "+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(s.restorePage) } func noCache(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") next.ServeHTTP(w, r) }) } // serveInfrastructure answers /infrastructure/* from an on-disk directory // (infrastructureDir), or 404 if unconfigured. A strict, same-origin-only // CSP: the guide's pages carry no inline scripts/styles and make no outside // requests (unlike "/", which allows connect-src https: for one client-side // admin check), so this can be tighter than the rest of the site. Never // cached — like the embedded pages, the guide must never outlive a deploy. func (s *Server) serveInfrastructure(w http.ResponseWriter, r *http.Request) { if s.infrastructureDir == "" { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; "+ "object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") http.StripPrefix("/infrastructure/", http.FileServer(http.Dir(s.infrastructureDir))).ServeHTTP(w, r) } // ---- auth helpers --------------------------------------------------------- // bearerToken pulls the token out of "Authorization: Bearer ". func bearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { return strings.TrimPrefix(h, "Bearer ") } return "" } // linkUser authenticates a request by device link token; ""/false when absent // or unknown. Hashing happens here — the store is keyed by hash and never sees // a presented plaintext. func (s *Server) linkUser(r *http.Request) (string, bool) { tok := bearerToken(r) if tok == "" { return "", false } return s.store.linkTokenUser(hashToken(tok)) } // ceremonyUser authenticates a request by fresh ceremony proof (the // X-Ceremony-Proof header, minted by login/register finish within // ceremonyWindow). Required for destructive ops — never accept a link token. // Verification is stateless: the proof carries its uid + expiry under an HMAC // (stateless.go), so nothing is looked up. func (s *Server) ceremonyUser(r *http.Request) (string, bool) { proof := r.Header.Get("X-Ceremony-Proof") if proof == "" { return "", false } return s.keys.ceremonyUser(proof) } // ---- blob ------------------------------------------------------------------- func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } data, version, found, err := s.store.getBlob(uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if !found { http.Error(w, "no blob", http.StatusNotFound) return } etag := `"` + version + `"` w.Header().Set("ETag", etag) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("Content-Type", "application/octet-stream") w.Write(data) } func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // If-Match is mandatory: `"0"` claims "no blob yet", anything else names the // version being replaced. A mismatch means another device wrote first — the // client re-fetches, merges its (small) list, and retries. ifMatch := strings.Trim(r.Header.Get("If-Match"), `"`) if ifMatch == "" { http.Error(w, "If-Match required", http.StatusPreconditionRequired) return } data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes)) if err != nil { http.Error(w, "blob too large", http.StatusRequestEntityTooLarge) return } if len(data) == 0 { http.Error(w, "empty blob", http.StatusBadRequest) return } newVersion, conflict, err := s.store.putBlob(uid, data, ifMatch) if errors.Is(err, errBadVersion) { http.Error(w, "bad If-Match", http.StatusBadRequest) return } if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if conflict { w.Header().Set("ETag", `"`+newVersion+`"`) http.Error(w, "version conflict", http.StatusConflict) return } w.Header().Set("ETag", `"`+newVersion+`"`) // The version is an opaque string on the wire, like the ETag — clients // already treat it that way (#550 Phase 0), so a future backend's // non-numeric versions need no client change. writeJSON(w, http.StatusOK, map[string]any{"version": newVersion}) } func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized) return } if err := s.store.deleteBlob(uid); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // ---- devices ---------------------------------------------------------------- func (s *Server) listDevicesHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } devices, err := s.store.listDevices(uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } current := s.store.linkTokenID(hashToken(bearerToken(r))) for i := range devices { devices[i].Current = devices[i].ID == current } writeJSON(w, http.StatusOK, map[string]any{"devices": devices}) } func (s *Server) deleteDeviceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized) return } deleted, err := s.store.deleteDevice(uid, r.PathValue("id")) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if !deleted { http.Error(w, "no such device", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // ---- credentials ----------------------------------------------------------------- func (s *Server) deleteCredentialHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized) return } deleted, last, err := s.store.deleteCredential(uid, r.PathValue("id")) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if last { // Removing the account's LAST sign-in method would brick it: the blob // stays live with no way to ever get in again. This used to be a // last-PASSKEY guard; under the anchor model (#1089) the store counts // SiwA and email anchors too, so an account holding another anchor CAN // shed its final passkey. Winding an account down is the // (ceremony-gated) blob delete, explicitly. http.Error(w, "cannot remove your last way to sign in — delete the backup instead", http.StatusConflict) return } if !deleted { http.Error(w, "no such credential", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // ---- rate limiting ------------------------------------------------------------ // rateLimiter is a per-key (IP) token bucket. Buckets refill continuously and // idle ones are swept when the map grows, so memory stays bounded. type rateLimiter struct { mu sync.Mutex buckets map[string]*bucket burst float64 perSec float64 } type bucket struct { tokens float64 last time.Time } func newRateLimiter(burst, perSec float64) *rateLimiter { return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec} } func (rl *rateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() if len(rl.buckets) > 10000 { for k, b := range rl.buckets { if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { delete(rl.buckets, k) } } } b, ok := rl.buckets[key] if !ok { b = &bucket{tokens: rl.burst, last: now} rl.buckets[key] = b } b.tokens += now.Sub(b.last).Seconds() * rl.perSec if b.tokens > rl.burst { b.tokens = rl.burst } b.last = now if b.tokens < 1 { return false } b.tokens-- return true } func (s *Server) rateLimited(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { ip = r.RemoteAddr } if !s.limiter.allow(ip) { http.Error(w, "rate limited", http.StatusTooManyRequests) return } next(w, r) } } // ---- small helpers ------------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("write json: %v", err) } } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def }