eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package 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
28import (
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.
46type 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
57func 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.
74func 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.
89func (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 …).
105func demoBits(base, recentCreates int) int {
106 bits := base
107 for threshold := 5; recentCreates >= threshold; threshold *= 2 {
108 bits++
109 }
110 return bits
111}
112
113func (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).
128func (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.
145func (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.
159func (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.
172func (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
205const 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.
210func (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).
237func (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).
301func (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.
313func (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.
354func (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.
365type demoStatusCache struct {
366 mu sync.Mutex
367 entries map[string]demoStatusEntry
368}
369
370type demoStatusEntry struct {
371 demo bool
372 expiresAt int64 // unix seconds; 0 = none reported
373 until time.Time
374}
375
376const demoStatusTTL = 10 * time.Second
377const demoStatusCacheMax = 4096
378
379func (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
389func (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.
418func (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.
455func (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