// Hashcash admission for the fetch service (docs/fetch.md § Abuse posture). // The service is identity-free by design and, behind the router, can't even // tell callers apart by IP — so before it becomes load-bearing for the hosted // fleet, anonymous callers pay in CPU instead of identity: solve one // challenge, receive a sealed ~1h ticket, and every request rides (and is // rate-bucketed by) that ticket. One solve per client per hour, not per // request — an unfurl must stay instant. // // The scheme is home/pow.go's (space creation, #561), kept deliberately in // step: SHA-256(challenge ":" nonce) clearing `bits` leading zero bits, // sealed expiring single-use challenges, verification costing one hash. Off // by default (LCHAT_FETCH_POW_BITS unset/0) — self-hosters and dev setups // never see it. package main import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "fmt" "strconv" "sync" "time" ) const ( fetchPowChallengeWindow = 5 * time.Minute fetchPowTicketWindow = time.Hour ) // fetchPowGate is the admission gate; a nil *fetchPowGate means off. type fetchPowGate struct { bits int aead cipher.AEAD // sealed challenges + tickets; key minted into the service DB mu sync.Mutex used map[string]int64 // consumed challenge -> expiry (replay guard) } func newFetchPowGate(bits int, key []byte) (*fetchPowGate, error) { if bits <= 0 { return nil, nil } block, err := aes.NewCipher(key) if err != nil { return nil, err } aead, err := cipher.NewGCM(block) if err != nil { return nil, err } return &fetchPowGate{bits: bits, aead: aead, used: map[string]int64{}}, nil } // seal/open: base64url(iv||ciphertext), the crypto.js convention like the // preview cache's. func (g *fetchPowGate) seal(payload []byte) (string, error) { iv := make([]byte, g.aead.NonceSize()) if _, err := rand.Read(iv); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(g.aead.Seal(iv, iv, payload, nil)), nil } func (g *fetchPowGate) open(blob string) ([]byte, bool) { raw, err := base64.RawURLEncoding.DecodeString(blob) if err != nil || len(raw) < g.aead.NonceSize() { return nil, false } payload, err := g.aead.Open(nil, raw[:g.aead.NonceSize()], raw[g.aead.NonceSize():], nil) if err != nil { return nil, false } return payload, true } // mintChallenge issues one solvable challenge: seal(expiry || random). // Stateless until solved — only CONSUMED challenges take memory. func (g *fetchPowGate) mintChallenge() (string, error) { return g.sealExpiring(time.Now().Add(fetchPowChallengeWindow)) } func (g *fetchPowGate) sealExpiring(expiry time.Time) (string, error) { payload := make([]byte, 8+16) binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) if _, err := rand.Read(payload[8:]); err != nil { return "", err } return g.seal(payload) } // verifyAndConsume checks one solution: the challenge opens, hasn't expired // or been used, and the nonce clears the difficulty. A passing challenge is // consumed — one solve, one ticket. func (g *fetchPowGate) verifyAndConsume(challenge, nonce string) error { payload, ok := g.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 !fetchPowSolves(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 } // mintTicket issues the post-solve pass: same sealed shape, longer window, // no replay guard (a ticket is MEANT to be reused; the per-ticket rate // bucket is what bounds it). func (g *fetchPowGate) mintTicket() (string, error) { return g.sealExpiring(time.Now().Add(fetchPowTicketWindow)) } func (g *fetchPowGate) ticketOK(t string) bool { payload, ok := g.open(t) if !ok || len(payload) != 24 { return false } return time.Now().Unix() <= int64(binary.BigEndian.Uint64(payload)) } // fetchPowSolves / fetchLeadingZeroBits / fetchSolvePow mirror home/pow.go // (same package-main symbols exist there, unexported in that sibling binary's // package — keep the two in step). func fetchPowSolves(challenge, nonce string, bits int) bool { h := sha256.Sum256([]byte(challenge + ":" + nonce)) return fetchLeadingZeroBits(h[:]) >= bits } func fetchLeadingZeroBits(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 } // fetchSolvePow grinds nonces until one clears the difficulty — tests (at low // bits); the web and native clients carry their own solver loops. func fetchSolvePow(challenge string, bits int) string { for n := 0; ; n++ { nonce := strconv.Itoa(n) if fetchPowSolves(challenge, nonce, bits) { return nonce } } }