eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Hashcash admission for the fetch service (docs/fetch.md § Abuse posture).
2// The service is identity-free by design and, behind the router, can't even
3// tell callers apart by IP — so before it becomes load-bearing for the hosted
4// fleet, anonymous callers pay in CPU instead of identity: solve one
5// challenge, receive a sealed ~1h ticket, and every request rides (and is
6// rate-bucketed by) that ticket. One solve per client per hour, not per
7// request — an unfurl must stay instant.
8//
9// The scheme is home/pow.go's (space creation, #561), kept deliberately in
10// step: SHA-256(challenge ":" nonce) clearing `bits` leading zero bits,
11// sealed expiring single-use challenges, verification costing one hash. Off
12// by default (LCHAT_FETCH_POW_BITS unset/0) — self-hosters and dev setups
13// never see it.
14package main
15
16import (
17 "crypto/aes"
18 "crypto/cipher"
19 "crypto/rand"
20 "crypto/sha256"
21 "encoding/base64"
22 "encoding/binary"
23 "fmt"
24 "strconv"
25 "sync"
26 "time"
27)
28
29const (
30 fetchPowChallengeWindow = 5 * time.Minute
31 fetchPowTicketWindow = time.Hour
32)
33
34// fetchPowGate is the admission gate; a nil *fetchPowGate means off.
35type fetchPowGate struct {
36 bits int
37 aead cipher.AEAD // sealed challenges + tickets; key minted into the service DB
38 mu sync.Mutex
39 used map[string]int64 // consumed challenge -> expiry (replay guard)
40}
41
42func newFetchPowGate(bits int, key []byte) (*fetchPowGate, error) {
43 if bits <= 0 {
44 return nil, nil
45 }
46 block, err := aes.NewCipher(key)
47 if err != nil {
48 return nil, err
49 }
50 aead, err := cipher.NewGCM(block)
51 if err != nil {
52 return nil, err
53 }
54 return &fetchPowGate{bits: bits, aead: aead, used: map[string]int64{}}, nil
55}
56
57// seal/open: base64url(iv||ciphertext), the crypto.js convention like the
58// preview cache's.
59func (g *fetchPowGate) seal(payload []byte) (string, error) {
60 iv := make([]byte, g.aead.NonceSize())
61 if _, err := rand.Read(iv); err != nil {
62 return "", err
63 }
64 return base64.RawURLEncoding.EncodeToString(g.aead.Seal(iv, iv, payload, nil)), nil
65}
66
67func (g *fetchPowGate) open(blob string) ([]byte, bool) {
68 raw, err := base64.RawURLEncoding.DecodeString(blob)
69 if err != nil || len(raw) < g.aead.NonceSize() {
70 return nil, false
71 }
72 payload, err := g.aead.Open(nil, raw[:g.aead.NonceSize()], raw[g.aead.NonceSize():], nil)
73 if err != nil {
74 return nil, false
75 }
76 return payload, true
77}
78
79// mintChallenge issues one solvable challenge: seal(expiry || random).
80// Stateless until solved — only CONSUMED challenges take memory.
81func (g *fetchPowGate) mintChallenge() (string, error) {
82 return g.sealExpiring(time.Now().Add(fetchPowChallengeWindow))
83}
84
85func (g *fetchPowGate) sealExpiring(expiry time.Time) (string, error) {
86 payload := make([]byte, 8+16)
87 binary.BigEndian.PutUint64(payload, uint64(expiry.Unix()))
88 if _, err := rand.Read(payload[8:]); err != nil {
89 return "", err
90 }
91 return g.seal(payload)
92}
93
94// verifyAndConsume checks one solution: the challenge opens, hasn't expired
95// or been used, and the nonce clears the difficulty. A passing challenge is
96// consumed — one solve, one ticket.
97func (g *fetchPowGate) verifyAndConsume(challenge, nonce string) error {
98 payload, ok := g.open(challenge)
99 if !ok || len(payload) != 24 {
100 return fmt.Errorf("bad challenge")
101 }
102 expiry := int64(binary.BigEndian.Uint64(payload))
103 if time.Now().Unix() > expiry {
104 return fmt.Errorf("challenge expired")
105 }
106 if !fetchPowSolves(challenge, nonce, g.bits) {
107 return fmt.Errorf("proof of work does not check out")
108 }
109 g.mu.Lock()
110 defer g.mu.Unlock()
111 now := time.Now().Unix()
112 for c, exp := range g.used { // expire the replay guard as we go
113 if exp < now {
114 delete(g.used, c)
115 }
116 }
117 if _, replayed := g.used[challenge]; replayed {
118 return fmt.Errorf("challenge already used")
119 }
120 g.used[challenge] = expiry
121 return nil
122}
123
124// mintTicket issues the post-solve pass: same sealed shape, longer window,
125// no replay guard (a ticket is MEANT to be reused; the per-ticket rate
126// bucket is what bounds it).
127func (g *fetchPowGate) mintTicket() (string, error) {
128 return g.sealExpiring(time.Now().Add(fetchPowTicketWindow))
129}
130
131func (g *fetchPowGate) ticketOK(t string) bool {
132 payload, ok := g.open(t)
133 if !ok || len(payload) != 24 {
134 return false
135 }
136 return time.Now().Unix() <= int64(binary.BigEndian.Uint64(payload))
137}
138
139// fetchPowSolves / fetchLeadingZeroBits / fetchSolvePow mirror home/pow.go
140// (same package-main symbols exist there, unexported in that sibling binary's
141// package — keep the two in step).
142func fetchPowSolves(challenge, nonce string, bits int) bool {
143 h := sha256.Sum256([]byte(challenge + ":" + nonce))
144 return fetchLeadingZeroBits(h[:]) >= bits
145}
146
147func fetchLeadingZeroBits(h []byte) int {
148 bits := 0
149 for _, b := range h {
150 if b == 0 {
151 bits += 8
152 continue
153 }
154 for mask := byte(0x80); mask != 0; mask >>= 1 {
155 if b&mask != 0 {
156 return bits
157 }
158 bits++
159 }
160 }
161 return bits
162}
163
164// fetchSolvePow grinds nonces until one clears the difficulty — tests (at low
165// bits); the web and native clients carry their own solver loops.
166func fetchSolvePow(challenge string, bits int) string {
167 for n := 0; ; n++ {
168 nonce := strconv.Itoa(n)
169 if fetchPowSolves(challenge, nonce, bits) {
170 return nonce
171 }
172 }
173}
174