package main // Demo spaces from home (docs/demo-spaces.md, the gates slice): the // account-free try-it path and every abuse gate in front of it. A demo needs // no passkey and no invite — which makes this the one truly open creation // endpoint, so the graduated-friction ladder carries the weight identity // checks carry elsewhere: // // 1. Proof of work, MANDATORY and adaptive: hashcash difficulty rises with // the trailing hour's demo-creation velocity, so an attack makes minting // expensive without punishing a quiet Tuesday. The difficulty is sealed // INSIDE each challenge, so a rise between mint and solve never // invalidates honest work in flight. // 2. One demo per IP bucket per cycle: from creation until expiry + one full // TTL again (cooldown ≥ lifetime), a bucket can't mint another — serial // recreation converges to one rolling slot, no better than the free tier, // so the burner incentive is structurally gone. IPv6 buckets at /56; // buckets are HMACs under a per-process random key, so no raw IP is ever // stored and nothing here is linkable across a restart. // 3. The cloud's own gates (live-demo cap, creates budget, TTL) sit behind // all of this — home is the friction layer, cloud is the ledger of truth. // // Everything is in-memory by design: the HMAC key dies with the process, so // persisted claim rows would be dead weight after a restart. A restart resets // demo cooldowns — acceptable, since operators restart rarely and attackers // can't force it. Enabled by LCHAT_HOME_DEMO=1 (default off — ships dark). import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" ) // demoGate holds every home-side demo gate. nil = demos not offered here. type demoGate struct { baseBits int // hashcash floor (LCHAT_DEMO_POW_BITS); velocity escalates it keys *stateKeys key []byte // per-process random HMAC key for IP buckets (never stored) mu sync.Mutex used map[string]int64 // consumed challenges -> expiry (replay guard) claims map[string]int64 // bucket -> cooldown_until (the semaphore + cooldown) creates []int64 // recent create times (velocity for adaptive bits) } func newDemoGate(baseBits int, keys *stateKeys) (*demoGate, error) { key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, err } return &demoGate{ baseBits: baseBits, keys: keys, key: key, used: map[string]int64{}, claims: map[string]int64{}, }, nil } // clientIP prefers the router-stamped X-Forwarded-For (home is publicly // reachable only through the router, whose proxy SetXForwarded()s every // request) and falls back to RemoteAddr for direct/dev setups. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { first, _, _ := strings.Cut(xff, ",") return strings.TrimSpace(first) } ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return ip } // bucket maps an IP to its opaque rate-limit bucket: IPv4 per address, IPv6 // per /56 (rotating inside one delegation buys nothing), HMAC'd under the // per-process key so no raw IP lands in memory structures or logs. func (g *demoGate) bucket(ip string) string { base := ip if addr := net.ParseIP(ip); addr != nil { if v4 := addr.To4(); v4 != nil { base = v4.String() } else { base = addr.Mask(net.CIDRMask(56, 128)).String() } } mac := hmac.New(sha256.New, g.key) mac.Write([]byte(base)) return hex.EncodeToString(mac.Sum(nil))[:32] } // bits is the CURRENT difficulty: the base, plus one bit at 5 creates in the // trailing hour and another per doubling after (5→+1, 10→+2, 20→+3 …). func demoBits(base, recentCreates int) int { bits := base for threshold := 5; recentCreates >= threshold; threshold *= 2 { bits++ } return bits } func (g *demoGate) bits(now time.Time) int { g.mu.Lock() defer g.mu.Unlock() cutoff := now.Add(-time.Hour).Unix() kept := g.creates[:0] for _, t := range g.creates { if t >= cutoff { kept = append(kept, t) } } g.creates = kept return demoBits(g.baseBits, len(kept)) } // cooldownUntil reports when this bucket may next mint a demo (0 = now). func (g *demoGate) cooldownUntil(bucket string, now time.Time) int64 { g.mu.Lock() defer g.mu.Unlock() for b, until := range g.claims { // prune as we go if until < now.Unix() { delete(g.claims, b) } } if until, ok := g.claims[bucket]; ok && until > now.Unix() { return until } return 0 } // record notes a successful create: the bucket blocks until the demo's expiry // plus one full lifetime again (cooldown ≥ TTL), and the create feeds the // velocity window. func (g *demoGate) record(bucket string, now time.Time, expiresAt int64) { ttl := expiresAt - now.Unix() if ttl <= 0 { ttl = 48 * 3600 // expiry missing/odd: block generously rather than not at all } g.mu.Lock() defer g.mu.Unlock() g.claims[bucket] = expiresAt + ttl g.creates = append(g.creates, now.Unix()) } // mint seals a challenge bound to a bucket AND its difficulty: // expiry(8) || bits(2) || random(16) || bucket. Verification reads the bits // back out, so escalation between mint and solve never voids honest work. func (g *demoGate) mint(bucket string, bits int, expiry time.Time) (string, error) { payload := make([]byte, 8+2+16+len(bucket)) binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) binary.BigEndian.PutUint16(payload[8:], uint16(bits)) if _, err := rand.Read(payload[10:26]); err != nil { return "", err } copy(payload[26:], bucket) return g.keys.seal(payload) } // verify checks one solution against the challenge's own sealed difficulty, // consuming it — one challenge, one demo attempt. func (g *demoGate) verify(bucket, challenge, nonce string) error { payload, ok := g.keys.open(challenge) if !ok || len(payload) < 26 { return fmt.Errorf("bad challenge") } expiry := int64(binary.BigEndian.Uint64(payload)) if time.Now().Unix() > expiry { return fmt.Errorf("challenge expired") } if string(payload[26:]) != bucket { return fmt.Errorf("challenge is not this connection's") } bits := int(binary.BigEndian.Uint16(payload[8:])) if !powSolves(challenge, nonce, bits) { return fmt.Errorf("proof of work does not check out") } g.mu.Lock() defer g.mu.Unlock() now := time.Now().Unix() for c, exp := range g.used { if exp < now { delete(g.used, c) } } if _, replayed := g.used[challenge]; replayed { return fmt.Errorf("challenge already used") } g.used[challenge] = expiry return nil } // ---- HTTP surface --------------------------------------------------------------- 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." // demoChallengeHandler: POST /api/demo/challenge — mint a proof-of-work // challenge for this connection's bucket at the current adaptive difficulty. // Refuses early on cooldown so nobody grinds hashes for a doomed create. func (s *Server) demoChallengeHandler(w http.ResponseWriter, r *http.Request) { if s.demo == nil || s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer demo spaces", }) return } now := time.Now() bucket := s.demo.bucket(clientIP(r)) if until := s.demo.cooldownUntil(bucket, now); until > 0 { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until, }) return } bits := s.demo.bits(now) challenge, err := s.demo.mint(bucket, bits, now.Add(powChallengeWindow)) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"}) return } writeJSON(w, http.StatusOK, map[string]any{"bits": bits, "challenge": challenge}) } // demoCreateHandler: POST /api/demo — create a 24h demo space. No account, no // invite: the gates above are the whole boundary, and the proof of work is // mandatory (unlike free-space creation, where it's an operator knob). func (s *Server) demoCreateHandler(w http.ResponseWriter, r *http.Request) { if s.demo == nil || s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer demo spaces", }) return } now := time.Now() bucket := s.demo.bucket(clientIP(r)) if until := s.demo.cooldownUntil(bucket, now); until > 0 { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until, }) return } var req struct { Challenge string `json:"challenge"` Nonce string `json:"nonce"` } if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || req.Challenge == "" { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_required", "message": "Demos ask for a little proof of work first — try again.", }) return } if err := s.demo.verify(bucket, req.Challenge, req.Nonce); err != nil { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_failed", "message": "The proof of work didn't check out — try again.", }) return } // The requester is a fresh opaque id — a demo belongs to no account until // it's claimed (claiming is a later slice). rnd := make([]byte, 16) if _, err := rand.Read(rnd); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "create_failed"}) return } status, body, err := s.cloud.createDemoSpace("demo-" + hex.EncodeToString(rnd)) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "create_failed", "message": "could not reach the space provisioner", }) return } // Relay the provisioner's stable codes as-is (503 demo_busy/at_capacity // carry member-facing messages; 501 = cloud has demos off); flatten the rest. switch status { case http.StatusCreated: expires, _ := body["expires_at"].(float64) s.demo.record(bucket, now, int64(expires)) writeJSON(w, status, body) case http.StatusServiceUnavailable, http.StatusNotImplemented: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "create_failed", "message": "demo creation failed", }) } } // createDemoSpace forwards one demo provisioning request (kind=demo; the // cloud's live-demo cap + TTL own the rest of the lifecycle). func (c *cloudProvisioner) createDemoSpace(requester string) (int, map[string]any, error) { payload, _ := json.Marshal(map[string]string{"requester": requester, "kind": "demo"}) return c.do("POST", "/api/instances", payload) } // claimSpaceHandler: POST /api/spaces/claim — adopt a demo space as the // signed-in account's free space (docs/demo-spaces.md, the happy exit). // Link-token auth: claiming is additive, like creation. The cloud does the // real work (ownership transfer, quota, expiry guard); home just relays its // stable codes. Folding the claimed space into the account's encrypted // backup is the CLIENT's move, exactly like a create — the blob stays // content-blind. func (s *Server) claimSpaceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer space creation", }) return } var req struct { Host string `json:"host"` } if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Host) == "" { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "host required", }) return } status, body, err := s.cloud.claimSpace(strings.TrimSpace(req.Host), uid) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "claim_failed", "message": "could not reach the space provisioner", }) return } // Relay the stable codes (200 claimed; 409 quota/not-demo; 410 expired — // all carry member-facing messages); flatten anything unexpected. switch status { case http.StatusOK, http.StatusConflict, http.StatusGone: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "claim_failed", "message": "claiming this space failed", }) } } // claimSpace forwards one claim to the provisioner. func (c *cloudProvisioner) claimSpace(host, requester string) (int, map[string]any, error) { payload, _ := json.Marshal(map[string]string{"requester": requester}) return c.do("POST", "/api/instances/"+url.PathEscape(host)+"/claim", payload) } // demoStatusCache collapses the banner's repeated status reads (one per page // load and per tab refocus, on every open demo tab) into at most one // provisioner round trip per host per window. It exists so this public, // auto-fired read can stay OUT of the shared ceremony rate limiter without // becoming a relay amplifier at the cloud. Size-bounded: a flood of made-up // hosts degrades to uncached reads, never to unbounded memory. type demoStatusCache struct { mu sync.Mutex entries map[string]demoStatusEntry } type demoStatusEntry struct { demo bool expiresAt int64 // unix seconds; 0 = none reported until time.Time } const demoStatusTTL = 10 * time.Second const demoStatusCacheMax = 4096 func (c *demoStatusCache) get(host string, now time.Time) (demoStatusEntry, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[host] if !ok || now.After(e.until) { return demoStatusEntry{}, false } return e, true } func (c *demoStatusCache) put(host string, e demoStatusEntry, now time.Time) { c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { c.entries = map[string]demoStatusEntry{} } if len(c.entries) >= demoStatusCacheMax { for h, old := range c.entries { if now.After(old.until) { delete(c.entries, h) } } if len(c.entries) >= demoStatusCacheMax { return // still full of live entries — skip caching this one } } e.until = now.Add(demoStatusTTL) c.entries[host] = e } // demoStatusHandler: GET /api/spaces/demo-status?host=… — the in-space // countdown banner's live check. Public and CORS-open on purpose: the banner // renders on the demo space's own origin before any account exists, and a // claim flips the cloud ledger without restarting the instance, so this is // the only truth the banner can trust. It answers "still a demo, and until // when" and nothing else — claimed, ordinary, and never-existed hosts all // come back {demo:false}, so it can't probe what exists or who pays. Gated on // s.cloud only (NOT s.demo): live demos must keep their countdown even if the // create gate is later switched off. func (s *Server) demoStatusHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") if s.cloud == nil { writeJSON(w, http.StatusOK, map[string]any{"demo": false}) return } host := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("host"))) if host == "" || len(host) > 255 { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "host required", }) return } now := time.Now() e, hit := s.demoStatus.get(host, now) if !hit { status, body, err := s.cloud.demoStatus(host) if err != nil || status != http.StatusOK { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "status_failed"}) return } e.demo, _ = body["demo"].(bool) if exp, ok := body["expires_at"].(float64); ok && e.demo { e.expiresAt = int64(exp) } s.demoStatus.put(host, e, now) } // Re-shape rather than relay: only the two fields the banner needs cross // the public boundary, whatever the provisioner adds later. out := map[string]any{"demo": e.demo} if e.demo && e.expiresAt > 0 { out["expires_at"] = e.expiresAt } writeJSON(w, http.StatusOK, out) } // demoStatus forwards one status read to the provisioner. func (c *cloudProvisioner) demoStatus(host string) (int, map[string]any, error) { return c.do("GET", "/api/instances/"+url.PathEscape(host)+"/demo-status", nil) }