eleven / messenger Public

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>
Paul Campbell b332a94c2c9910e64f21beaba7c727eca16379c3 parent fbd1523
7 files changed, +379 −3
  • docs/cloud-provisioner.md +8 −1
  • docs/tier-economics.md +5 −1
  • home/main.go +11 −0
  • home/pow.go +131 −0
  • home/pow_test.go +146 −0
  • home/provision.go +42 −0
  • home/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" ' +