Files
chat/internal/handlers/handlers.go
clawbot bf4d63bc4d
Some checks failed
check / check (push) Failing after 1m48s
feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam (#79)
closes #12

## Summary

Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via `MODE +H <bits>`, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.

## Changes

### Database
- Added `hashcash_bits` column to `channels` table (default 0 = no requirement)
- Added `spent_hashcash` table with `stamp_hash` unique key and `created_at` for TTL pruning
- New queries: `GetChannelHashcashBits`, `SetChannelHashcashBits`, `RecordSpentHashcash`, `IsHashcashSpent`, `PruneSpentHashcash`

### Hashcash Validation (`internal/hashcash/channel.go`)
- `ChannelValidator` type for per-channel stamp validation
- `BodyHash()` computes hex-encoded SHA-256 of message body
- `StampHash()` computes deterministic hash of stamp for spent-token key
- `MintChannelStamp()` generates valid stamps (for clients)
- Stamp format: `1:bits:YYMMDD:channel:bodyhash:counter`
- Validates: version, difficulty, date freshness (48h), channel binding, body hash binding, proof-of-work

### Handler Changes (`internal/handlers/api.go`)
- `validateChannelHashcash()` + `verifyChannelStamp()` — checks hashcash on PRIVMSG to protected channels
- `extractHashcashFromMeta()` — parses hashcash stamp from meta JSON
- `applyChannelMode()` / `setHashcashMode()` / `clearHashcashMode()` — MODE +H/-H support
- `queryChannelMode()` — shows +nH in mode query when hashcash is set
- Meta field now passed through the full dispatch chain (dispatchCommand → handlePrivmsg → handleChannelMsg → sendChannelMsg → fanOut → InsertMessage)
- ISUPPORT updated: `CHANMODES=,H,,imnst` (H in type B = parameter when set)

### Replay Prevention
- Spent stamps persisted to SQLite `spent_hashcash` table
- 1-year TTL (per issue requirements)
- Automatic pruning in cleanup loop

### Client Support (`internal/cli/api/hashcash.go`)
- `MintChannelHashcash(bits, channel, body)` — computes stamps for channel messages

### Tests
- **12 unit tests** in `internal/hashcash/channel_test.go`: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-trip
- **10 integration tests** in `internal/handlers/api_test.go`: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits arg

### README
- Added `+H` to channel modes table
- Added "Per-Channel Hashcash (Anti-Spam)" section with full documentation
- Updated `meta` field description to mention hashcash

## How It Works

1. Channel operator sets requirement: `MODE #general +H 20` (20 bits)
2. Client mints stamp: computes SHA-256 hashcash bound to `#general` + SHA-256(body)
3. Client sends PRIVMSG with `meta.hashcash` field containing the stamp
4. Server validates stamp, checks spent cache, records as spent, relays message
5. Replayed stamps are rejected for 1 year

## Docker Build

`docker build .` passes clean (formatting, linting, all tests).

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #79
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 03:40:33 +01:00

312 lines
6.6 KiB
Go

// Package handlers provides HTTP request handlers for the neoirc server.
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
var errUnauthorized = errors.New("unauthorized")
// Params defines the dependencies for creating Handlers.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
}
const defaultIdleTimeout = 30 * 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
// New creates a new Handlers instance.
func New(
lifecycle fx.Lifecycle,
params Params,
) (*Handlers, error) {
resource := params.Config.ServerName
if resource == "" {
resource = "neoirc"
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
hdlr.startCleanup(ctx)
return nil
},
OnStop: func(_ context.Context) error {
hdlr.stopCleanup()
return nil
},
})
return hdlr, nil
}
func (hdlr *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
writer.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
writer.WriteHeader(status)
if data != nil {
err := json.NewEncoder(writer).Encode(data)
if err != nil {
hdlr.log.Error(
"json encode error", "error", err,
)
}
}
}
func (hdlr *Handlers) respondError(
writer http.ResponseWriter,
request *http.Request,
msg string,
status int,
) {
hdlr.respondJSON(
writer, request,
map[string]string{"error": msg},
status,
)
}
func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
return defaultIdleTimeout
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid SESSION_IDLE_TIMEOUT, using default",
"value", raw, "error", err,
)
return defaultIdleTimeout
}
return dur
}
// startCleanup launches the idle-user cleanup goroutine.
// We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
}
func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup()
}
}
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
timeout := hdlr.idleTimeout()
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hdlr.runCleanup(ctx, timeout)
case <-ctx.Done():
return
}
}
}
func (hdlr *Handlers) runCleanup(
ctx context.Context,
timeout time.Duration,
) {
cutoff := time.Now().Add(-timeout)
// Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"user cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale users",
"deleted", deleted,
)
}
hdlr.pruneQueuesAndMessages(ctx)
}
// parseDurationConfig parses a Go duration string,
// returning zero on empty input and logging on error.
func (hdlr *Handlers) parseDurationConfig(
name, raw string,
) time.Duration {
if raw == "" {
return 0
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid duration config, skipping",
"name", name, "value", raw, "error", err,
)
return 0
}
return dur
}
// pruneQueuesAndMessages removes old client output queue
// entries per QUEUE_MAX_AGE and old messages per
// MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context,
) {
queueMaxAge := hdlr.parseDurationConfig(
"QUEUE_MAX_AGE",
hdlr.params.Config.QueueMaxAge,
)
if queueMaxAge > 0 {
queueCutoff := time.Now().Add(-queueMaxAge)
pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff)
if err != nil {
hdlr.log.Error(
"client output queue pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old client output queue entries",
"deleted", pruned,
)
}
}
messageMaxAge := hdlr.parseDurationConfig(
"MESSAGE_MAX_AGE",
hdlr.params.Config.MessageMaxAge,
)
if messageMaxAge > 0 {
msgCutoff := time.Now().Add(-messageMaxAge)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil {
hdlr.log.Error(
"message pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old messages",
"deleted", pruned,
)
}
}
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
}