Some checks failed
check / check (push) Failing after 1m48s
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>
99 lines
2.1 KiB
Go
99 lines
2.1 KiB
Go
package neoircapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
)
|
|
|
|
const (
|
|
// bitsPerByte is the number of bits in a byte.
|
|
bitsPerByte = 8
|
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
fullByteMask = 0xFF
|
|
// counterSpace is the range for random counter seeds.
|
|
counterSpace = 1 << 48
|
|
)
|
|
|
|
// MintHashcash computes a hashcash stamp with the given
|
|
// difficulty (leading zero bits) and resource string.
|
|
func MintHashcash(bits int, resource string) string {
|
|
date := time.Now().UTC().Format("060102")
|
|
prefix := fmt.Sprintf(
|
|
"1:%d:%s:%s::", bits, date, resource,
|
|
)
|
|
|
|
for {
|
|
counter := randomCounter()
|
|
stamp := prefix + counter
|
|
hash := sha256.Sum256([]byte(stamp))
|
|
|
|
if hasLeadingZeroBits(hash[:], bits) {
|
|
return stamp
|
|
}
|
|
}
|
|
}
|
|
|
|
// MintChannelHashcash computes a hashcash stamp bound to
|
|
// a specific channel and message body. The stamp format
|
|
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
|
// bodyhash is the hex-encoded SHA-256 of the message
|
|
// body bytes. Delegates to the internal/hashcash package.
|
|
func MintChannelHashcash(
|
|
bits int,
|
|
channel string,
|
|
body []byte,
|
|
) string {
|
|
bodyHash := hashcash.BodyHash(body)
|
|
|
|
return hashcash.MintChannelStamp(
|
|
bits, channel, bodyHash,
|
|
)
|
|
}
|
|
|
|
// hasLeadingZeroBits checks if hash has at least numBits
|
|
// leading zero bits.
|
|
func hasLeadingZeroBits(
|
|
hash []byte,
|
|
numBits int,
|
|
) bool {
|
|
fullBytes := numBits / bitsPerByte
|
|
remainBits := numBits % bitsPerByte
|
|
|
|
for idx := range fullBytes {
|
|
if hash[idx] != 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if remainBits > 0 && fullBytes < len(hash) {
|
|
mask := byte(
|
|
fullByteMask << (bitsPerByte - remainBits),
|
|
)
|
|
|
|
if hash[fullBytes]&mask != 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// randomCounter generates a random hex counter string.
|
|
func randomCounter() string {
|
|
counterVal, err := rand.Int(
|
|
rand.Reader, big.NewInt(counterSpace),
|
|
)
|
|
if err != nil {
|
|
// Fallback to timestamp-based counter on error.
|
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
}
|
|
|
|
return hex.EncodeToString(counterVal.Bytes())
|
|
}
|