home: hashcash at space creation (#561) — the burn runs inside the fader (#675)
LCHAT_POW_BITS (default 0/off) gates POST /api/spaces behind classic hashcash: a sealed, expiring, single-use challenge bound to the account (stateKeys.seal), solved client-side during the fader's 'Hold on…' build phase via async SHA-256 (the animation keeps breathing), verified in one hash. Prices the Sybil loop (invite yourself → fresh account → 3 more free spaces) in CPU. Docs record the gate + the free tier's 1 GB cap.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+379
−3
docs/cloud-provisioner.md+8 −1docs/tier-economics.md+5 −1home/main.go+11 −0home/pow.go+131 −0home/pow_test.go+146 −0home/provision.go+42 −0home/web/static/home.js+36 −1
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 39a0b01..dc50c24 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -197,7 +197,14 @@ POST /api/spaces/{host}/checkout bearer: device link token {tier} |
| ``` |
| Creation and listing are link-token-authed (additive/reads) and creation is |
| -rate-limited per IP; the real abuse bound is the provisioner's quota. **Creation |
| +rate-limited per IP; the real abuse bound is the provisioner's quota. **With |
| +`LCHAT_POW_BITS` set on home (default off), creation also demands hashcash**: |
| +`POST /api/spaces/challenge` mints a sealed, expiring, single-use challenge |
| +bound to the account; the client grinds SHA-256(challenge:nonce) to that many |
| +leading zero bits during the fader's build phase and submits it with the |
| +create. Verification is one hash; a consumed challenge never buys a second |
| +space. This prices the Sybil loop (invite yourself → fresh account → 3 more |
| +free spaces) in CPU. **Creation |
| is also gated to existing members:** `POST /api/spaces` returns `403 not_eligible` |
| unless the caller already has a backup blob. A blob only exists once you've |
| enrolled ≥1 space, and enrolling needs a space you were *invited* into — so this |
diff --git a/docs/tier-economics.md b/docs/tier-economics.md| index 8cf6bec..8d710dd 100644 |
| --- a/docs/tier-economics.md |
| +++ b/docs/tier-economics.md |
| @@ -38,9 +38,13 @@ larger storage allowance (below). |
| ## Storage allowances (as of 2026-07-11 — insurance-grade) |
| +Space creation is also gated by an account quota (3 free spaces; paid spaces |
| +don't count) and, when the home server enables it, a hashcash proof-of-work |
| +per creation — the anti-Sybil cost that runs inside the create animation. |
| + |
| | Tier | Included storage | |
| |---|---| |
| -| Free | 2 GB | |
| +| Free | 1 GB | |
| | Family | 5 GB | |
| | Community & Friends | 10 GB | |
| | Business | 25 GB | |
diff --git a/home/main.go b/home/main.go| index 922bafb..57421b2 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -19,6 +19,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "strconv" |
| "strings" |
| "sync" |
| "time" |
| @@ -51,6 +52,9 @@ type Server struct { |
| // cloud is the optional space provisioner client (see provision.go); nil |
| // means this home server doesn't offer space creation. |
| cloud *cloudProvisioner |
| + // pow gates space creation behind hashcash when LCHAT_POW_BITS > 0 |
| + // (pow.go); nil = no gate. |
| + pow *powGate |
| // origin is this server's public origin (also the WebAuthn RP origin) — |
| // used to build the checkout return URLs. |
| origin string |
| @@ -91,6 +95,10 @@ func main() { |
| if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { |
| log.Printf("space provisioning via %s", s.cloud.url) |
| } |
| + if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 { |
| + s.pow = newPowGate(bits, s.keys) |
| + log.Printf("space creation gated by proof of work (%d bits)", bits) |
| + } |
| log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin) |
| if err := serve(*addr, s.handler()); err != nil { |
| log.Fatal(err) |
| @@ -230,6 +238,9 @@ func (s *Server) handler() http.Handler { |
| // server doesn't offer it (public — it reveals only whether the feature is on). |
| mux.HandleFunc("GET /api/features", s.featuresHandler) |
| mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler)) |
| + // The proof-of-work challenge behind creation (cheap to mint; rate-limited |
| + // with the same budget as the creates it gates). |
| + mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler)) |
| // Which spaces this user provisioned (read; drives the delete affordance)… |
| mux.HandleFunc("GET /api/spaces", s.listSpacesHandler) |
| // …and permanent deletion of one — ceremony-gated like every destructive op. |
diff --git a/home/pow.go b/home/pow.go| new file mode 100644 |
| index 0000000..e419038 |
| --- /dev/null |
| +++ b/home/pow.go |
| @@ -0,0 +1,131 @@ |
| +package main |
| + |
| +// Hashcash at space creation (#561): the client burns a few seconds of CPU |
| +// per space, making the Sybil loop (mint invites → fresh accounts → 3 more |
| +// free spaces each) cost compute instead of nothing. The burn runs during the |
| +// fader's "Hold on…" build phase — the cost IS the animation. |
| +// |
| +// Classic hashcash: the server hands out a sealed, expiring, single-use |
| +// challenge bound to the requesting account; the client finds a nonce where |
| +// SHA-256(challenge ":" nonce) has at least `bits` leading zero bits; |
| +// verification is one hash. Asymmetric by construction — the server never |
| +// works. Off by default (LCHAT_POW_BITS unset/0): self-hosters and dev setups |
| +// never see it. |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "fmt" |
| + "strconv" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// powChallengeWindow bounds how long a minted challenge stays solvable. Long |
| +// enough for slow hardware at production difficulty, short enough that a |
| +// stockpile of pre-mined challenges goes stale. |
| +const powChallengeWindow = 5 * time.Minute |
| + |
| +// powGate is the creation gate. nil = disabled. |
| +type powGate struct { |
| + bits int |
| + keys *stateKeys |
| + mu sync.Mutex |
| + used map[string]int64 // consumed challenge -> expiry (replay guard) |
| +} |
| + |
| +func newPowGate(bits int, keys *stateKeys) *powGate { |
| + if bits <= 0 { |
| + return nil |
| + } |
| + return &powGate{bits: bits, keys: keys, used: map[string]int64{}} |
| +} |
| + |
| +// mint issues a challenge for one account: seal(expiry || random || uid). |
| +// Stateless until solved — only CONSUMED challenges take memory, and those |
| +// expire out of the map. |
| +func (g *powGate) mint(uid string) (string, error) { |
| + return g.mintAt(uid, time.Now().Add(powChallengeWindow)) |
| +} |
| + |
| +// mintAt is the expiry-injectable body of mint, so tests can mint stale |
| +// challenges without a clock. |
| +func (g *powGate) mintAt(uid string, expiry time.Time) (string, error) { |
| + payload := make([]byte, 8+16+len(uid)) |
| + binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) |
| + if _, err := rand.Read(payload[8:24]); err != nil { |
| + return "", err |
| + } |
| + copy(payload[24:], uid) |
| + return g.keys.seal(payload) |
| +} |
| + |
| +// verify checks one solution: the challenge opens, is bound to this account, |
| +// hasn't expired or been used, and the nonce clears the difficulty. A passing |
| +// challenge is consumed — one challenge, one space. |
| +func (g *powGate) verify(uid, challenge, nonce string) error { |
| + payload, ok := g.keys.open(challenge) |
| + if !ok || len(payload) < 24 { |
| + return fmt.Errorf("bad challenge") |
| + } |
| + expiry := int64(binary.BigEndian.Uint64(payload)) |
| + if time.Now().Unix() > expiry { |
| + return fmt.Errorf("challenge expired") |
| + } |
| + if string(payload[24:]) != uid { |
| + return fmt.Errorf("challenge is not yours") |
| + } |
| + if !powSolves(challenge, nonce, g.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 { // expire the replay guard as we go |
| + 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 |
| +} |
| + |
| +// powSolves reports whether SHA-256(challenge ":" nonce) clears `bits` |
| +// leading zero bits — the one-hash verification (and the test suite's solver |
| +// building block). |
| +func powSolves(challenge, nonce string, bits int) bool { |
| + h := sha256.Sum256([]byte(challenge + ":" + nonce)) |
| + return leadingZeroBits(h[:]) >= bits |
| +} |
| + |
| +func leadingZeroBits(h []byte) int { |
| + bits := 0 |
| + for _, b := range h { |
| + if b == 0 { |
| + bits += 8 |
| + continue |
| + } |
| + for mask := byte(0x80); mask != 0; mask >>= 1 { |
| + if b&mask != 0 { |
| + return bits |
| + } |
| + bits++ |
| + } |
| + } |
| + return bits |
| +} |
| + |
| +// solvePow grinds nonces until one clears the difficulty — used by tests (at |
| +// low bits) and by any Go client; the web client has its own solver loop. |
| +func solvePow(challenge string, bits int) string { |
| + for n := 0; ; n++ { |
| + nonce := strconv.Itoa(n) |
| + if powSolves(challenge, nonce, bits) { |
| + return nonce |
| + } |
| + } |
| +} |
diff --git a/home/pow_test.go b/home/pow_test.go| new file mode 100644 |
| index 0000000..1bc6f77 |
| --- /dev/null |
| +++ b/home/pow_test.go |
| @@ -0,0 +1,146 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testGate(t *testing.T, bits int) *powGate { |
| + t.Helper() |
| + keys, err := newStateKeys([]byte("test-secret")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return newPowGate(bits, keys) |
| +} |
| + |
| +func TestPowMintVerify(t *testing.T) { |
| + g := testGate(t, 8) // 8 bits ≈ 256 hashes — instant in tests |
| + challenge, err := g.mint("uid-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := solvePow(challenge, 8) |
| + |
| + // Wrong account, wrong nonce, then the real thing — which consumes it. |
| + if err := g.verify("uid-2", challenge, nonce); err == nil { |
| + t.Error("someone else's challenge verified") |
| + } |
| + if err := g.verify("uid-1", challenge, "not-the-nonce"); err == nil { |
| + t.Error("bad nonce verified") |
| + } |
| + if err := g.verify("uid-1", challenge, nonce); err != nil { |
| + t.Fatalf("valid solution rejected: %v", err) |
| + } |
| + // Replay: one challenge, one space. |
| + if err := g.verify("uid-1", challenge, nonce); err == nil { |
| + t.Error("replayed challenge verified") |
| + } |
| + |
| + // A tampered challenge never opens. |
| + if err := g.verify("uid-1", challenge+"x", nonce); err == nil { |
| + t.Error("tampered challenge verified") |
| + } |
| + // An expired one is refused even with a valid solution. |
| + stale, err := g.mintAt("uid-1", time.Now().Add(-time.Minute)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := g.verify("uid-1", stale, solvePow(stale, 8)); err == nil { |
| + t.Error("expired challenge verified") |
| + } |
| + |
| + if newPowGate(0, g.keys) != nil { |
| + t.Error("bits 0 should disable the gate") |
| + } |
| +} |
| + |
| +func TestLeadingZeroBits(t *testing.T) { |
| + for _, c := range []struct { |
| + in []byte |
| + want int |
| + }{ |
| + {[]byte{0x80}, 0}, {[]byte{0x01}, 7}, {[]byte{0x00, 0xff}, 8}, |
| + {[]byte{0x00, 0x0f}, 12}, {[]byte{0x00, 0x00}, 16}, |
| + } { |
| + if got := leadingZeroBits(c.in); got != c.want { |
| + t.Errorf("leadingZeroBits(%v) = %d, want %d", c.in, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestCreateSpaceRequiresPow(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + uid, token := seedMember(t, s) |
| + cloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{ |
| + "host": "h.example.com", "url": "https://h.example.com", "setup_link": "https://h.example.com/join/setup#S", |
| + }) |
| + s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| + s.pow = newPowGate(8, s.keys) |
| + |
| + post := func(body string) *http.Response { |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| + } |
| + |
| + // No proof / garbage proof → 403 with the stable codes. |
| + if r := post(""); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("no proof: %d, want 403", r.StatusCode) |
| + } |
| + if r := post(`{"challenge":"junk","nonce":"1"}`); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("junk proof: %d, want 403", r.StatusCode) |
| + } |
| + |
| + // The real flow: challenge → solve → create. |
| + creq, _ := http.NewRequest("POST", ts.URL+"/api/spaces/challenge", nil) |
| + creq.Header.Set("Authorization", "Bearer "+token) |
| + cresp, err := http.DefaultClient.Do(creq) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer cresp.Body.Close() |
| + var ch struct { |
| + Bits int `json:"bits"` |
| + Challenge string `json:"challenge"` |
| + } |
| + json.NewDecoder(cresp.Body).Decode(&ch) |
| + if ch.Bits != 8 || ch.Challenge == "" { |
| + t.Fatalf("challenge = %+v", ch) |
| + } |
| + body, _ := json.Marshal(map[string]string{"challenge": ch.Challenge, "nonce": solvePow(ch.Challenge, ch.Bits)}) |
| + if r := post(string(body)); r.StatusCode != http.StatusCreated { |
| + t.Fatalf("solved create: %d, want 201", r.StatusCode) |
| + } |
| + // The same proof can't buy a second space. |
| + if r := post(string(body)); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("replayed proof: %d, want 403", r.StatusCode) |
| + } |
| + |
| + // Gate off → bits 0 and bodyless creation works (the deployed default). |
| + s.pow = nil |
| + cresp2, err := http.DefaultClient.Do(creq.Clone(creq.Context())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer cresp2.Body.Close() |
| + var off struct { |
| + Bits int `json:"bits"` |
| + } |
| + json.NewDecoder(cresp2.Body).Decode(&off) |
| + if off.Bits != 0 { |
| + t.Errorf("gate off: bits = %d, want 0", off.Bits) |
| + } |
| + if r := post(""); r.StatusCode != http.StatusCreated { |
| + t.Errorf("gate off create: %d, want 201", r.StatusCode) |
| + } |
| + _ = uid |
| +} |
diff --git a/home/provision.go b/home/provision.go| index db783c4..1af7643 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -51,6 +51,27 @@ func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"spaces": s.cloud != nil}) |
| } |
| +// challengeHandler: POST /api/spaces/challenge — mint a proof-of-work |
| +// challenge for the signed-in account (pow.go). bits 0 = the gate is off and |
| +// creation needs no proof; the client checks this instead of a feature flag. |
| +func (s *Server) challengeHandler(w http.ResponseWriter, r *http.Request) { |
| + uid, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if s.pow == nil { |
| + writeJSON(w, http.StatusOK, map[string]any{"bits": 0}) |
| + return |
| + } |
| + challenge, err := s.pow.mint(uid) |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"bits": s.pow.bits, "challenge": challenge}) |
| +} |
| + |
| // createSpaceHandler: POST /api/spaces — provision a new space for the |
| // signed-in user. Auth is the device link token (creating a space is additive, |
| // not destructive, so no fresh ceremony is demanded); the provisioner's |
| @@ -84,6 +105,27 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| + // The proof-of-work gate (pow.go): when enabled, creation carries a solved |
| + // challenge. Checked AFTER the cheap gates so an ineligible caller never |
| + // burns CPU for a refusal, and before the provisioner is ever dialed. |
| + if s.pow != nil { |
| + 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": "This home server asks for a little proof of work first — try again.", |
| + }) |
| + return |
| + } |
| + if err := s.pow.verify(uid, 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 |
| + } |
| + } |
| status, body, err := s.cloud.createSpace(uid) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index ccef510..2e170f9 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -721,9 +721,14 @@ async function startUpgrade(host, tier, box) { |
| // finds the way back. Throws a member-facing Error for the fader's error path. |
| async function requestSpace() { |
| if (!vault) throw new Error("Sign in first — your new space is saved into your account."); |
| + // The proof-of-work gate, when this home server runs one: fetch a challenge |
| + // and grind it out right here, during the fader's build phase — the CPU |
| + // burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve. |
| + const proof = await solveCreateChallenge(); |
| const res = await fetch("/api/spaces", { |
| method: "POST", |
| - headers: { Authorization: "Bearer " + state.token }, |
| + headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify(proof || {}), |
| }); |
| const body = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| @@ -744,6 +749,36 @@ async function requestSpace() { |
| return { host, setupLink: body.setup_link }; |
| } |
| +// solveCreateChallenge grinds the home server's hashcash challenge: find a |
| +// nonce where SHA-256(challenge + ":" + nonce) clears the difficulty. Async |
| +// digests yield to the event loop, so the fader's build animation keeps |
| +// breathing while we work. null when the server runs no gate. |
| +async function solveCreateChallenge() { |
| + const res = await fetch("/api/spaces/challenge", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token }, |
| + }); |
| + const c = await res.json().catch(() => ({})); |
| + if (!res.ok || !c.bits) return null; |
| + const enc = new TextEncoder(); |
| + for (let n = 0; ; n++) { |
| + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); |
| + if (leadingZeroBits(digest) >= c.bits) return { challenge: c.challenge, nonce: String(n) }; |
| + } |
| +} |
| + |
| +function leadingZeroBits(bytes) { |
| + let bits = 0; |
| + for (const b of bytes) { |
| + if (b === 0) { bits += 8; continue; } |
| + for (let mask = 128; mask; mask >>= 1) { |
| + if (b & mask) return bits; |
| + bits++; |
| + } |
| + } |
| + return bits; |
| +} |
| + |
| // A checkmark for the single-serve confirmation (static, in-code SVG). |
| const CHECK_SVG = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" ' + |