1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "crypto/hmac"
8 "crypto/rand"
9 "crypto/sha256"
10 "encoding/base64"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "math/bits"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/bnema/zerowrap"
20
21 "bnema.dev/ermite/internal/domain"
22 "bnema.dev/ermite/internal/ports"
23)
24
25// Parameters of the anonymous submission (ADR 0031).
26const (
27 // baseDifficulty is the proof-of-work of a client that has not submitted
28 // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29 // one second for a SHA-256 in JavaScript on a mid-range phone.
30 baseDifficulty = 18
31 // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32 maxDifficulty = 21
33 // perAddrStep and globalStep: one more bit per that many submissions of the
34 // last hour from the client, and from everyone.
35 perAddrStep = 2
36 globalStep = 20
37
38 // challengeTTL is how long a challenge may be solved and sent.
39 challengeTTL = 10 * time.Minute
40 // minFillTime is the least time between the challenge and the submission:
41 // a human cannot write an issue faster.
42 minFillTime = 3 * time.Second
43 // maxSpent bounds the nonces remembered as spent. Past it submissions are
44 // paused rather than a live nonce forgotten.
45 maxSpent = 100_000
46
47 tokenVersion = 1
48 nonceLen = 16
49 maxChallengeLen = 1024
50 maxSolutionLen = 20
51 spentSweep = time.Minute
52)
53
54// difficultyFor is the proof-of-work for a client with perAddr submissions in
55// the last hour among global ones: base, plus a bit per perAddrStep and per
56// globalStep, at most limit. It decays as the sliding window empties.
57func difficultyFor(base, limit, perAddr, global int) int {
58 return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59}
60
61// leadingZeroBits counts the zero bits at the start of sum.
62func leadingZeroBits(sum []byte) int {
63 n := 0
64 for _, b := range sum {
65 if b == 0 {
66 n += 8
67 continue
68 }
69 return n + bits.LeadingZeros8(b)
70 }
71 return n
72}
73
74// powHash is SHA-256(nonce || ":" || solution).
75func powHash(nonce []byte, solution string) []byte {
76 h := sha256.New()
77 h.Write(nonce)
78 h.Write([]byte(":"))
79 h.Write([]byte(solution))
80 return h.Sum(nil)
81}
82
83// challengeClaims is the signed payload of a challenge token.
84type challengeClaims struct {
85 V int `json:"v"`
86 Repo string `json:"repo"`
87 Nonce []byte `json:"nonce"`
88 Difficulty int `json:"d"`
89 IssuedAt int64 `json:"iat"`
90 ExpiresAt int64 `json:"exp"`
91 IP []byte `json:"ip"`
92}
93
94// IssueSubmissions is the anonymous submission of issues: a signed challenge
95// with an adaptive proof of work, and every server-side check before the issue
96// awaits moderation.
97type IssueSubmissions struct {
98 meta ports.RepoMetaStore
99 pending ports.PendingIssueStore
100 bans ports.IssueBanStore
101 limiter ports.SubmissionLimiter
102 keys *IssueKeys
103 bus ports.EventBus
104 clock ports.Clock
105 base int
106 limit int
107 minFill time.Duration
108
109 admitMu sync.Mutex // serializes the pending cap check and the store
110
111 mu sync.Mutex
112 spent map[string]time.Time // nonce -> expiry of its challenge
113 nextSweep time.Time
114}
115
116var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
117
118// IssueSubmissionsConfig holds what the submission needs. Every field but the
119// difficulties is required.
120type IssueSubmissionsConfig struct {
121 Meta ports.RepoMetaStore
122 Pending ports.PendingIssueStore
123 Bans ports.IssueBanStore
124 Limiter ports.SubmissionLimiter
125 Keys *IssueKeys
126 Bus ports.EventBus
127 Clock ports.Clock
128 // BaseDifficulty and MaxDifficulty replace the production constants; for
129 // tests. Zero keeps the constants.
130 BaseDifficulty, MaxDifficulty int
131 // MinFillTime replaces minFillTime when positive; for tests.
132 MinFillTime time.Duration
133}
134
135// NewIssueSubmissions builds the submission use case.
136func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
137 s := &IssueSubmissions{
138 meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
139 keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
140 base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
141 spent: make(map[string]time.Time),
142 }
143 if cfg.BaseDifficulty > 0 {
144 s.base = cfg.BaseDifficulty
145 }
146 if cfg.MaxDifficulty > 0 {
147 s.limit = cfg.MaxDifficulty
148 }
149 if cfg.MinFillTime > 0 {
150 s.minFill = cfg.MinFillTime
151 }
152 return s
153}
154
155// openRepo returns the repository if anyone may submit to it, else
156// domain.ErrNotFound.
157func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
158 r, err := lookupRepo(ctx, s.meta, repo)
159 if err != nil {
160 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
161 return domain.ErrNotFound
162 }
163 return err
164 }
165 if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() {
166 return domain.ErrNotFound
167 }
168 return nil
169}
170
171func (s *IssueSubmissions) sign(key, msg []byte) []byte {
172 m := hmac.New(sha256.New, key)
173 m.Write(msg)
174 return m.Sum(nil)
175}
176
177// Challenge issues a challenge for a client of the repository.
178func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
179 if err := ctx.Err(); err != nil {
180 return ports.IssueChallenge{}, err
181 }
182 if err := s.openRepo(ctx, repo); err != nil {
183 return ports.IssueChallenge{}, err
184 }
185 nonce := make([]byte, nonceLen)
186 if _, err := rand.Read(nonce); err != nil {
187 return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
188 }
189 perAddr, global := s.limiter.Recent(ctx, addr)
190 d := difficultyFor(s.base, s.limit, perAddr, global)
191 now := s.clock.Now().UTC()
192 claims := challengeClaims{
193 V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
194 IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
195 }
196 payload, err := json.Marshal(claims)
197 if err != nil {
198 return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
199 }
200 return ports.IssueChallenge{
201 Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
202 Nonce: enc64(nonce),
203 Difficulty: d,
204 FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
205 IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
206 MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
207 ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
208 }, nil
209}
210
211// parseToken verifies the signature and returns the claims, or false.
212func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
213 var c challengeClaims
214 if len(tok) > maxChallengeLen {
215 return c, false
216 }
217 p, sig, ok := strings.Cut(tok, ".")
218 if !ok {
219 return c, false
220 }
221 payload, err := base64.RawURLEncoding.DecodeString(p)
222 if err != nil {
223 return c, false
224 }
225 got, err := base64.RawURLEncoding.DecodeString(sig)
226 if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
227 return c, false
228 }
229 if json.Unmarshal(payload, &c) != nil {
230 return c, false
231 }
232 return c, true
233}
234
235func validSolution(sol string) bool {
236 if len(sol) < 1 || len(sol) > maxSolutionLen {
237 return false
238 }
239 for i := 0; i < len(sol); i++ {
240 if sol[i] < '0' || sol[i] > '9' {
241 return false
242 }
243 }
244 return true
245}
246
247// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
248// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
249func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
250 s.mu.Lock()
251 defer s.mu.Unlock()
252 if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
253 for k, e := range s.spent {
254 if now.After(e) { // an expired challenge is refused anyway
255 delete(s.spent, k)
256 }
257 }
258 s.nextSweep = now.Add(spentSweep)
259 }
260 key := string(nonce)
261 if _, dup := s.spent[key]; dup {
262 return domain.ErrSubmissionInvalid
263 }
264 if len(s.spent) >= maxSpent {
265 return domain.ErrSubmissionsPaused
266 }
267 s.spent[key] = exp
268 return nil
269}
270
271// Submit checks the submission and stores it as a pending issue. The checks
272// run in a fixed order and stop at the first failure; every bot-related
273// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
274// logged (without address, hash or text).
275func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
276 if err := ctx.Err(); err != nil {
277 return err
278 }
279 log := zerowrap.FromCtx(ctx)
280 refuse := func(reason string, err error) error {
281 log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
282 return err
283 }
284 if err := s.openRepo(ctx, repo); err != nil {
285 return err
286 }
287 ipHash := s.keys.IPHash(addr)
288 now := s.clock.Now().UTC()
289 if f.Honeypot != "" {
290 return refuse("honeypot", domain.ErrSubmissionInvalid)
291 }
292 c, ok := s.parseToken(f.Token)
293 if !ok {
294 return refuse("bad_token", domain.ErrSubmissionInvalid)
295 }
296 exp := time.Unix(c.ExpiresAt, 0)
297 switch {
298 case c.V != tokenVersion || len(c.Nonce) != nonceLen:
299 return refuse("bad_token", domain.ErrSubmissionInvalid)
300 case c.Repo != repo.String():
301 return refuse("token_other_repo", domain.ErrSubmissionInvalid)
302 case now.After(exp):
303 return refuse("token_expired", domain.ErrSubmissionInvalid)
304 case !hmac.Equal(c.IP, ipHash):
305 return refuse("token_other_client", domain.ErrSubmissionInvalid)
306 }
307 if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
308 return refuse("bad_form_token", domain.ErrSubmissionInvalid)
309 }
310 if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
311 return refuse("too_fast", domain.ErrSubmissionInvalid)
312 }
313 if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
314 leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
315 return refuse("bad_pow", domain.ErrSubmissionInvalid)
316 }
317 if err := s.spend(c.Nonce, exp, now); err != nil {
318 if errors.Is(err, domain.ErrSubmissionsPaused) {
319 return refuse("spent_full", err)
320 }
321 return refuse("replayed", err)
322 }
323 banned, err := s.bans.Banned(ctx, ipHash, now)
324 if err != nil {
325 return fmt.Errorf("check ban: %w", err)
326 }
327 if banned {
328 return refuse("banned", domain.ErrSubmissionInvalid)
329 }
330 title, err := domain.ParseIssueTitle(f.Title)
331 if err != nil {
332 return refuse("text_title", fmt.Errorf("title: %w", err))
333 }
334 body, err := domain.ParseIssueBody(f.Body)
335 if err != nil {
336 return refuse("text_body", fmt.Errorf("body: %w", err))
337 }
338 author, err := domain.ParseAuthorLabel(f.Author)
339 if err != nil {
340 return refuse("text_author", fmt.Errorf("author: %w", err))
341 }
342 id, err := domain.NewPendingIssueID()
343 if err != nil {
344 return fmt.Errorf("new pending issue id: %w", err)
345 }
346 if err := s.admit(ctx, addr, refuse, domain.PendingIssue{
347 ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
348 }); err != nil {
349 return err
350 }
351 log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
352 s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
353 return nil
354}
355
356// admit reserves the client's quota, checks the global queue cap and stores
357// the issue. Ermite is a single process (ADR 0001), so admitMu serializes the
358// Count -> Put section: concurrent submissions cannot exceed
359// domain.MaxPendingIssues. It would not hold with several processes.
360func (s *IssueSubmissions) admit(ctx context.Context, addr string, refuse func(string, error) error, p domain.PendingIssue) error {
361 s.admitMu.Lock()
362 defer s.admitMu.Unlock()
363 ok, after, release := s.limiter.Reserve(ctx, addr)
364 if !ok {
365 return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
366 }
367 n, err := s.pending.Count(ctx)
368 if err != nil {
369 release()
370 return fmt.Errorf("count pending issues: %w", err)
371 }
372 if n >= domain.MaxPendingIssues {
373 release()
374 return refuse("pending_full", domain.ErrSubmissionsPaused)
375 }
376 if err := s.pending.Put(ctx, p); err != nil {
377 release()
378 return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
379 }
380 return nil
381}
382
383func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }