| 1 | package main |
| 2 | |
| 3 | // Demo spaces from home (docs/demo-spaces.md, the gates slice): the |
| 4 | // account-free try-it path and every abuse gate in front of it. A demo needs |
| 5 | // no passkey and no invite — which makes this the one truly open creation |
| 6 | // endpoint, so the graduated-friction ladder carries the weight identity |
| 7 | // checks carry elsewhere: |
| 8 | // |
| 9 | // 1. Proof of work, MANDATORY and adaptive: hashcash difficulty rises with |
| 10 | // the trailing hour's demo-creation velocity, so an attack makes minting |
| 11 | // expensive without punishing a quiet Tuesday. The difficulty is sealed |
| 12 | // INSIDE each challenge, so a rise between mint and solve never |
| 13 | // invalidates honest work in flight. |
| 14 | // 2. One demo per IP bucket per cycle: from creation until expiry + one full |
| 15 | // TTL again (cooldown ≥ lifetime), a bucket can't mint another — serial |
| 16 | // recreation converges to one rolling slot, no better than the free tier, |
| 17 | // so the burner incentive is structurally gone. IPv6 buckets at /56; |
| 18 | // buckets are HMACs under a per-process random key, so no raw IP is ever |
| 19 | // stored and nothing here is linkable across a restart. |
| 20 | // 3. The cloud's own gates (live-demo cap, creates budget, TTL) sit behind |
| 21 | // all of this — home is the friction layer, cloud is the ledger of truth. |
| 22 | // |
| 23 | // Everything is in-memory by design: the HMAC key dies with the process, so |
| 24 | // persisted claim rows would be dead weight after a restart. A restart resets |
| 25 | // demo cooldowns — acceptable, since operators restart rarely and attackers |
| 26 | // can't force it. Enabled by LCHAT_HOME_DEMO=1 (default off — ships dark). |
| 27 | |
| 28 | import ( |
| 29 | "crypto/hmac" |
| 30 | "crypto/rand" |
| 31 | "crypto/sha256" |
| 32 | "encoding/binary" |
| 33 | "encoding/hex" |
| 34 | "encoding/json" |
| 35 | "fmt" |
| 36 | "io" |
| 37 | "net" |
| 38 | "net/http" |
| 39 | "net/url" |
| 40 | "strings" |
| 41 | "sync" |
| 42 | "time" |
| 43 | ) |
| 44 | |
| 45 | // demoGate holds every home-side demo gate. nil = demos not offered here. |
| 46 | type demoGate struct { |
| 47 | baseBits int // hashcash floor (LCHAT_DEMO_POW_BITS); velocity escalates it |
| 48 | keys *stateKeys |
| 49 | key []byte // per-process random HMAC key for IP buckets (never stored) |
| 50 | |
| 51 | mu sync.Mutex |
| 52 | used map[string]int64 // consumed challenges -> expiry (replay guard) |
| 53 | claims map[string]int64 // bucket -> cooldown_until (the semaphore + cooldown) |
| 54 | creates []int64 // recent create times (velocity for adaptive bits) |
| 55 | } |
| 56 | |
| 57 | func newDemoGate(baseBits int, keys *stateKeys) (*demoGate, error) { |
| 58 | key := make([]byte, 32) |
| 59 | if _, err := rand.Read(key); err != nil { |
| 60 | return nil, err |
| 61 | } |
| 62 | return &demoGate{ |
| 63 | baseBits: baseBits, |
| 64 | keys: keys, |
| 65 | key: key, |
| 66 | used: map[string]int64{}, |
| 67 | claims: map[string]int64{}, |
| 68 | }, nil |
| 69 | } |
| 70 | |
| 71 | // clientIP prefers the router-stamped X-Forwarded-For (home is publicly |
| 72 | // reachable only through the router, whose proxy SetXForwarded()s every |
| 73 | // request) and falls back to RemoteAddr for direct/dev setups. |
| 74 | func clientIP(r *http.Request) string { |
| 75 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| 76 | first, _, _ := strings.Cut(xff, ",") |
| 77 | return strings.TrimSpace(first) |
| 78 | } |
| 79 | ip, _, err := net.SplitHostPort(r.RemoteAddr) |
| 80 | if err != nil { |
| 81 | return r.RemoteAddr |
| 82 | } |
| 83 | return ip |
| 84 | } |
| 85 | |
| 86 | // bucket maps an IP to its opaque rate-limit bucket: IPv4 per address, IPv6 |
| 87 | // per /56 (rotating inside one delegation buys nothing), HMAC'd under the |
| 88 | // per-process key so no raw IP lands in memory structures or logs. |
| 89 | func (g *demoGate) bucket(ip string) string { |
| 90 | base := ip |
| 91 | if addr := net.ParseIP(ip); addr != nil { |
| 92 | if v4 := addr.To4(); v4 != nil { |
| 93 | base = v4.String() |
| 94 | } else { |
| 95 | base = addr.Mask(net.CIDRMask(56, 128)).String() |
| 96 | } |
| 97 | } |
| 98 | mac := hmac.New(sha256.New, g.key) |
| 99 | mac.Write([]byte(base)) |
| 100 | return hex.EncodeToString(mac.Sum(nil))[:32] |
| 101 | } |
| 102 | |
| 103 | // bits is the CURRENT difficulty: the base, plus one bit at 5 creates in the |
| 104 | // trailing hour and another per doubling after (5→+1, 10→+2, 20→+3 …). |
| 105 | func demoBits(base, recentCreates int) int { |
| 106 | bits := base |
| 107 | for threshold := 5; recentCreates >= threshold; threshold *= 2 { |
| 108 | bits++ |
| 109 | } |
| 110 | return bits |
| 111 | } |
| 112 | |
| 113 | func (g *demoGate) bits(now time.Time) int { |
| 114 | g.mu.Lock() |
| 115 | defer g.mu.Unlock() |
| 116 | cutoff := now.Add(-time.Hour).Unix() |
| 117 | kept := g.creates[:0] |
| 118 | for _, t := range g.creates { |
| 119 | if t >= cutoff { |
| 120 | kept = append(kept, t) |
| 121 | } |
| 122 | } |
| 123 | g.creates = kept |
| 124 | return demoBits(g.baseBits, len(kept)) |
| 125 | } |
| 126 | |
| 127 | // cooldownUntil reports when this bucket may next mint a demo (0 = now). |
| 128 | func (g *demoGate) cooldownUntil(bucket string, now time.Time) int64 { |
| 129 | g.mu.Lock() |
| 130 | defer g.mu.Unlock() |
| 131 | for b, until := range g.claims { // prune as we go |
| 132 | if until < now.Unix() { |
| 133 | delete(g.claims, b) |
| 134 | } |
| 135 | } |
| 136 | if until, ok := g.claims[bucket]; ok && until > now.Unix() { |
| 137 | return until |
| 138 | } |
| 139 | return 0 |
| 140 | } |
| 141 | |
| 142 | // record notes a successful create: the bucket blocks until the demo's expiry |
| 143 | // plus one full lifetime again (cooldown ≥ TTL), and the create feeds the |
| 144 | // velocity window. |
| 145 | func (g *demoGate) record(bucket string, now time.Time, expiresAt int64) { |
| 146 | ttl := expiresAt - now.Unix() |
| 147 | if ttl <= 0 { |
| 148 | ttl = 48 * 3600 // expiry missing/odd: block generously rather than not at all |
| 149 | } |
| 150 | g.mu.Lock() |
| 151 | defer g.mu.Unlock() |
| 152 | g.claims[bucket] = expiresAt + ttl |
| 153 | g.creates = append(g.creates, now.Unix()) |
| 154 | } |
| 155 | |
| 156 | // mint seals a challenge bound to a bucket AND its difficulty: |
| 157 | // expiry(8) || bits(2) || random(16) || bucket. Verification reads the bits |
| 158 | // back out, so escalation between mint and solve never voids honest work. |
| 159 | func (g *demoGate) mint(bucket string, bits int, expiry time.Time) (string, error) { |
| 160 | payload := make([]byte, 8+2+16+len(bucket)) |
| 161 | binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) |
| 162 | binary.BigEndian.PutUint16(payload[8:], uint16(bits)) |
| 163 | if _, err := rand.Read(payload[10:26]); err != nil { |
| 164 | return "", err |
| 165 | } |
| 166 | copy(payload[26:], bucket) |
| 167 | return g.keys.seal(payload) |
| 168 | } |
| 169 | |
| 170 | // verify checks one solution against the challenge's own sealed difficulty, |
| 171 | // consuming it — one challenge, one demo attempt. |
| 172 | func (g *demoGate) verify(bucket, challenge, nonce string) error { |
| 173 | payload, ok := g.keys.open(challenge) |
| 174 | if !ok || len(payload) < 26 { |
| 175 | return fmt.Errorf("bad challenge") |
| 176 | } |
| 177 | expiry := int64(binary.BigEndian.Uint64(payload)) |
| 178 | if time.Now().Unix() > expiry { |
| 179 | return fmt.Errorf("challenge expired") |
| 180 | } |
| 181 | if string(payload[26:]) != bucket { |
| 182 | return fmt.Errorf("challenge is not this connection's") |
| 183 | } |
| 184 | bits := int(binary.BigEndian.Uint16(payload[8:])) |
| 185 | if !powSolves(challenge, nonce, bits) { |
| 186 | return fmt.Errorf("proof of work does not check out") |
| 187 | } |
| 188 | g.mu.Lock() |
| 189 | defer g.mu.Unlock() |
| 190 | now := time.Now().Unix() |
| 191 | for c, exp := range g.used { |
| 192 | if exp < now { |
| 193 | delete(g.used, c) |
| 194 | } |
| 195 | } |
| 196 | if _, replayed := g.used[challenge]; replayed { |
| 197 | return fmt.Errorf("challenge already used") |
| 198 | } |
| 199 | g.used[challenge] = expiry |
| 200 | return nil |
| 201 | } |
| 202 | |
| 203 | // ---- HTTP surface --------------------------------------------------------------- |
| 204 | |
| 205 | const demoCooldownMsg = "This connection already tried a demo recently — demos come around about once a day. Creating an account gets you a free space that doesn't disappear." |
| 206 | |
| 207 | // demoChallengeHandler: POST /api/demo/challenge — mint a proof-of-work |
| 208 | // challenge for this connection's bucket at the current adaptive difficulty. |
| 209 | // Refuses early on cooldown so nobody grinds hashes for a doomed create. |
| 210 | func (s *Server) demoChallengeHandler(w http.ResponseWriter, r *http.Request) { |
| 211 | if s.demo == nil || s.cloud == nil { |
| 212 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 213 | "error": "unavailable", "message": "this home server does not offer demo spaces", |
| 214 | }) |
| 215 | return |
| 216 | } |
| 217 | now := time.Now() |
| 218 | bucket := s.demo.bucket(clientIP(r)) |
| 219 | if until := s.demo.cooldownUntil(bucket, now); until > 0 { |
| 220 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 221 | "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until, |
| 222 | }) |
| 223 | return |
| 224 | } |
| 225 | bits := s.demo.bits(now) |
| 226 | challenge, err := s.demo.mint(bucket, bits, now.Add(powChallengeWindow)) |
| 227 | if err != nil { |
| 228 | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"}) |
| 229 | return |
| 230 | } |
| 231 | writeJSON(w, http.StatusOK, map[string]any{"bits": bits, "challenge": challenge}) |
| 232 | } |
| 233 | |
| 234 | // demoCreateHandler: POST /api/demo — create a 24h demo space. No account, no |
| 235 | // invite: the gates above are the whole boundary, and the proof of work is |
| 236 | // mandatory (unlike free-space creation, where it's an operator knob). |
| 237 | func (s *Server) demoCreateHandler(w http.ResponseWriter, r *http.Request) { |
| 238 | if s.demo == nil || s.cloud == nil { |
| 239 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 240 | "error": "unavailable", "message": "this home server does not offer demo spaces", |
| 241 | }) |
| 242 | return |
| 243 | } |
| 244 | now := time.Now() |
| 245 | bucket := s.demo.bucket(clientIP(r)) |
| 246 | if until := s.demo.cooldownUntil(bucket, now); until > 0 { |
| 247 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 248 | "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until, |
| 249 | }) |
| 250 | return |
| 251 | } |
| 252 | var req struct { |
| 253 | Challenge string `json:"challenge"` |
| 254 | Nonce string `json:"nonce"` |
| 255 | } |
| 256 | if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || req.Challenge == "" { |
| 257 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 258 | "error": "pow_required", "message": "Demos ask for a little proof of work first — try again.", |
| 259 | }) |
| 260 | return |
| 261 | } |
| 262 | if err := s.demo.verify(bucket, req.Challenge, req.Nonce); err != nil { |
| 263 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 264 | "error": "pow_failed", "message": "The proof of work didn't check out — try again.", |
| 265 | }) |
| 266 | return |
| 267 | } |
| 268 | |
| 269 | // The requester is a fresh opaque id — a demo belongs to no account until |
| 270 | // it's claimed (claiming is a later slice). |
| 271 | rnd := make([]byte, 16) |
| 272 | if _, err := rand.Read(rnd); err != nil { |
| 273 | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "create_failed"}) |
| 274 | return |
| 275 | } |
| 276 | status, body, err := s.cloud.createDemoSpace("demo-" + hex.EncodeToString(rnd)) |
| 277 | if err != nil { |
| 278 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 279 | "error": "create_failed", "message": "could not reach the space provisioner", |
| 280 | }) |
| 281 | return |
| 282 | } |
| 283 | // Relay the provisioner's stable codes as-is (503 demo_busy/at_capacity |
| 284 | // carry member-facing messages; 501 = cloud has demos off); flatten the rest. |
| 285 | switch status { |
| 286 | case http.StatusCreated: |
| 287 | expires, _ := body["expires_at"].(float64) |
| 288 | s.demo.record(bucket, now, int64(expires)) |
| 289 | writeJSON(w, status, body) |
| 290 | case http.StatusServiceUnavailable, http.StatusNotImplemented: |
| 291 | writeJSON(w, status, body) |
| 292 | default: |
| 293 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 294 | "error": "create_failed", "message": "demo creation failed", |
| 295 | }) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // createDemoSpace forwards one demo provisioning request (kind=demo; the |
| 300 | // cloud's live-demo cap + TTL own the rest of the lifecycle). |
| 301 | func (c *cloudProvisioner) createDemoSpace(requester string) (int, map[string]any, error) { |
| 302 | payload, _ := json.Marshal(map[string]string{"requester": requester, "kind": "demo"}) |
| 303 | return c.do("POST", "/api/instances", payload) |
| 304 | } |
| 305 | |
| 306 | // claimSpaceHandler: POST /api/spaces/claim — adopt a demo space as the |
| 307 | // signed-in account's free space (docs/demo-spaces.md, the happy exit). |
| 308 | // Link-token auth: claiming is additive, like creation. The cloud does the |
| 309 | // real work (ownership transfer, quota, expiry guard); home just relays its |
| 310 | // stable codes. Folding the claimed space into the account's encrypted |
| 311 | // backup is the CLIENT's move, exactly like a create — the blob stays |
| 312 | // content-blind. |
| 313 | func (s *Server) claimSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| 314 | uid, ok := s.linkUser(r) |
| 315 | if !ok { |
| 316 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 317 | return |
| 318 | } |
| 319 | if s.cloud == nil { |
| 320 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 321 | "error": "unavailable", "message": "this home server does not offer space creation", |
| 322 | }) |
| 323 | return |
| 324 | } |
| 325 | var req struct { |
| 326 | Host string `json:"host"` |
| 327 | } |
| 328 | if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Host) == "" { |
| 329 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 330 | "error": "bad_request", "message": "host required", |
| 331 | }) |
| 332 | return |
| 333 | } |
| 334 | status, body, err := s.cloud.claimSpace(strings.TrimSpace(req.Host), uid) |
| 335 | if err != nil { |
| 336 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 337 | "error": "claim_failed", "message": "could not reach the space provisioner", |
| 338 | }) |
| 339 | return |
| 340 | } |
| 341 | // Relay the stable codes (200 claimed; 409 quota/not-demo; 410 expired — |
| 342 | // all carry member-facing messages); flatten anything unexpected. |
| 343 | switch status { |
| 344 | case http.StatusOK, http.StatusConflict, http.StatusGone: |
| 345 | writeJSON(w, status, body) |
| 346 | default: |
| 347 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 348 | "error": "claim_failed", "message": "claiming this space failed", |
| 349 | }) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | // claimSpace forwards one claim to the provisioner. |
| 354 | func (c *cloudProvisioner) claimSpace(host, requester string) (int, map[string]any, error) { |
| 355 | payload, _ := json.Marshal(map[string]string{"requester": requester}) |
| 356 | return c.do("POST", "/api/instances/"+url.PathEscape(host)+"/claim", payload) |
| 357 | } |
| 358 | |
| 359 | // demoStatusCache collapses the banner's repeated status reads (one per page |
| 360 | // load and per tab refocus, on every open demo tab) into at most one |
| 361 | // provisioner round trip per host per window. It exists so this public, |
| 362 | // auto-fired read can stay OUT of the shared ceremony rate limiter without |
| 363 | // becoming a relay amplifier at the cloud. Size-bounded: a flood of made-up |
| 364 | // hosts degrades to uncached reads, never to unbounded memory. |
| 365 | type demoStatusCache struct { |
| 366 | mu sync.Mutex |
| 367 | entries map[string]demoStatusEntry |
| 368 | } |
| 369 | |
| 370 | type demoStatusEntry struct { |
| 371 | demo bool |
| 372 | expiresAt int64 // unix seconds; 0 = none reported |
| 373 | until time.Time |
| 374 | } |
| 375 | |
| 376 | const demoStatusTTL = 10 * time.Second |
| 377 | const demoStatusCacheMax = 4096 |
| 378 | |
| 379 | func (c *demoStatusCache) get(host string, now time.Time) (demoStatusEntry, bool) { |
| 380 | c.mu.Lock() |
| 381 | defer c.mu.Unlock() |
| 382 | e, ok := c.entries[host] |
| 383 | if !ok || now.After(e.until) { |
| 384 | return demoStatusEntry{}, false |
| 385 | } |
| 386 | return e, true |
| 387 | } |
| 388 | |
| 389 | func (c *demoStatusCache) put(host string, e demoStatusEntry, now time.Time) { |
| 390 | c.mu.Lock() |
| 391 | defer c.mu.Unlock() |
| 392 | if c.entries == nil { |
| 393 | c.entries = map[string]demoStatusEntry{} |
| 394 | } |
| 395 | if len(c.entries) >= demoStatusCacheMax { |
| 396 | for h, old := range c.entries { |
| 397 | if now.After(old.until) { |
| 398 | delete(c.entries, h) |
| 399 | } |
| 400 | } |
| 401 | if len(c.entries) >= demoStatusCacheMax { |
| 402 | return // still full of live entries — skip caching this one |
| 403 | } |
| 404 | } |
| 405 | e.until = now.Add(demoStatusTTL) |
| 406 | c.entries[host] = e |
| 407 | } |
| 408 | |
| 409 | // demoStatusHandler: GET /api/spaces/demo-status?host=… — the in-space |
| 410 | // countdown banner's live check. Public and CORS-open on purpose: the banner |
| 411 | // renders on the demo space's own origin before any account exists, and a |
| 412 | // claim flips the cloud ledger without restarting the instance, so this is |
| 413 | // the only truth the banner can trust. It answers "still a demo, and until |
| 414 | // when" and nothing else — claimed, ordinary, and never-existed hosts all |
| 415 | // come back {demo:false}, so it can't probe what exists or who pays. Gated on |
| 416 | // s.cloud only (NOT s.demo): live demos must keep their countdown even if the |
| 417 | // create gate is later switched off. |
| 418 | func (s *Server) demoStatusHandler(w http.ResponseWriter, r *http.Request) { |
| 419 | w.Header().Set("Access-Control-Allow-Origin", "*") |
| 420 | if s.cloud == nil { |
| 421 | writeJSON(w, http.StatusOK, map[string]any{"demo": false}) |
| 422 | return |
| 423 | } |
| 424 | host := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("host"))) |
| 425 | if host == "" || len(host) > 255 { |
| 426 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 427 | "error": "bad_request", "message": "host required", |
| 428 | }) |
| 429 | return |
| 430 | } |
| 431 | now := time.Now() |
| 432 | e, hit := s.demoStatus.get(host, now) |
| 433 | if !hit { |
| 434 | status, body, err := s.cloud.demoStatus(host) |
| 435 | if err != nil || status != http.StatusOK { |
| 436 | writeJSON(w, http.StatusBadGateway, map[string]any{"error": "status_failed"}) |
| 437 | return |
| 438 | } |
| 439 | e.demo, _ = body["demo"].(bool) |
| 440 | if exp, ok := body["expires_at"].(float64); ok && e.demo { |
| 441 | e.expiresAt = int64(exp) |
| 442 | } |
| 443 | s.demoStatus.put(host, e, now) |
| 444 | } |
| 445 | // Re-shape rather than relay: only the two fields the banner needs cross |
| 446 | // the public boundary, whatever the provisioner adds later. |
| 447 | out := map[string]any{"demo": e.demo} |
| 448 | if e.demo && e.expiresAt > 0 { |
| 449 | out["expires_at"] = e.expiresAt |
| 450 | } |
| 451 | writeJSON(w, http.StatusOK, out) |
| 452 | } |
| 453 | |
| 454 | // demoStatus forwards one status read to the provisioner. |
| 455 | func (c *cloudProvisioner) demoStatus(host string) (int, map[string]any, error) { |
| 456 | return c.do("GET", "/api/instances/"+url.PathEscape(host)+"/demo-status", nil) |
| 457 | } |
| 458 | |