v0.28.0 fca9155

issue_submissions.go RawHistoryFolder

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) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close