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 → another // free space 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 } } }