| 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. |
| 7 | package main |
| 8 | |
| 9 | import ( |
| 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. |
| 37 | const maxBlobBytes = 18 << 10 |
| 38 | |
| 39 | //go:embed web/pages/*.html web/static/* |
| 40 | var webFS embed.FS |
| 41 | |
| 42 | type 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 | |
| 119 | func 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. |
| 267 | func 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(). |
| 290 | func 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). |
| 320 | func 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). |
| 369 | func 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. |
| 391 | func (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. |
| 539 | func 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 | |
| 550 | func (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. |
| 578 | func (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. |
| 588 | func (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). |
| 599 | func (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 | |
| 609 | func 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. |
| 622 | func (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>". |
| 637 | func 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. |
| 648 | func (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. |
| 661 | func (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 | |
| 671 | func (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 | |
| 696 | func (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 | |
| 740 | func (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 | |
| 755 | func (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 | |
| 773 | func (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 | |
| 793 | func (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. |
| 825 | type rateLimiter struct { |
| 826 | mu sync.Mutex |
| 827 | buckets map[string]*bucket |
| 828 | burst float64 |
| 829 | perSec float64 |
| 830 | } |
| 831 | |
| 832 | type bucket struct { |
| 833 | tokens float64 |
| 834 | last time.Time |
| 835 | } |
| 836 | |
| 837 | func newRateLimiter(burst, perSec float64) *rateLimiter { |
| 838 | return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec} |
| 839 | } |
| 840 | |
| 841 | func (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 | |
| 869 | func (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 | |
| 885 | func 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 | |
| 893 | func envOr(key, def string) string { |
| 894 | if v := os.Getenv(key); v != "" { |
| 895 | return v |
| 896 | } |
| 897 | return def |
| 898 | } |
| 899 | |