Files
chat/internal/hashcash/channel.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

187 lines
3.8 KiB
Go

package hashcash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errBodyHashMismatch = errors.New(
"body hash mismatch",
)
errBodyHashMissing = errors.New(
"body hash missing",
)
)
// ChannelValidator checks hashcash stamps for
// per-channel PRIVMSG validation. It verifies that
// stamps are bound to a specific channel and message
// body. Replay prevention is handled externally via
// the database spent_hashcash table for persistence
// across server restarts (1-year TTL).
type ChannelValidator struct{}
// NewChannelValidator creates a ChannelValidator.
func NewChannelValidator() *ChannelValidator {
return &ChannelValidator{}
}
// BodyHash computes the hex-encoded SHA-256 hash of a
// message body for use in hashcash stamp validation.
func BodyHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// ValidateStamp checks a channel hashcash stamp. It
// verifies the stamp format, difficulty, date, channel
// binding, body hash binding, and proof-of-work. Replay
// detection is NOT performed here — callers must check
// the spent_hashcash table separately.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func (cv *ChannelValidator) ValidateStamp(
stamp string,
requiredBits int,
channel string,
bodyHash string,
) error {
if requiredBits <= 0 {
return nil
}
parts := strings.Split(stamp, ":")
if len(parts) != stampFields {
return fmt.Errorf(
"%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts),
)
}
version := parts[0]
bitsStr := parts[1]
dateStr := parts[2]
resource := parts[3]
stampBodyHash := parts[4]
headerErr := validateChannelHeader(
version, bitsStr, resource,
requiredBits, channel,
)
if headerErr != nil {
return headerErr
}
stampTime, parseErr := parseStampDate(dateStr)
if parseErr != nil {
return parseErr
}
timeErr := validateTime(stampTime)
if timeErr != nil {
return timeErr
}
bodyErr := validateBodyHash(
stampBodyHash, bodyHash,
)
if bodyErr != nil {
return bodyErr
}
return validateProof(stamp, requiredBits)
}
// StampHash returns a deterministic hash of a stamp
// string for use as a spent-token key.
func StampHash(stamp string) string {
hash := sha256.Sum256([]byte(stamp))
return hex.EncodeToString(hash[:])
}
func validateChannelHeader(
version, bitsStr, resource string,
requiredBits int,
channel string,
) error {
if version != stampVersion {
return fmt.Errorf(
"%w: %s", errBadVersion, version,
)
}
claimedBits, err := strconv.Atoi(bitsStr)
if err != nil || claimedBits < requiredBits {
return fmt.Errorf(
"%w: need %d bits",
errInsufficientBits, requiredBits,
)
}
if resource != channel {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, channel,
)
}
return nil
}
func validateBodyHash(
stampBodyHash, expectedBodyHash string,
) error {
if stampBodyHash == "" {
return errBodyHashMissing
}
if stampBodyHash != expectedBodyHash {
return fmt.Errorf(
"%w: got %q, want %q",
errBodyHashMismatch,
stampBodyHash, expectedBodyHash,
)
}
return nil
}
// MintChannelStamp computes a channel hashcash stamp
// with the given difficulty, channel name, and body hash.
// This is intended for clients to generate stamps before
// sending PRIVMSG to hashcash-protected channels.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func MintChannelStamp(
bits int,
channel string,
bodyHash string,
) string {
date := time.Now().UTC().Format(dateFormatShort)
prefix := fmt.Sprintf(
"1:%d:%s:%s:%s:",
bits, date, channel, bodyHash,
)
counter := uint64(0)
for {
stamp := prefix + strconv.FormatUint(counter, 16)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
counter++
}
}