feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam (#79)
Some checks failed
check / check (push) Failing after 1m48s
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>
This commit was merged in pull request #79.
This commit is contained in:
67
README.md
67
README.md
@@ -523,7 +523,7 @@ the same JSON envelope:
|
||||
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
|
||||
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
|
||||
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. |
|
||||
|
||||
**Important invariants:**
|
||||
|
||||
@@ -1012,13 +1012,14 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
||||
|
||||
Inspired by IRC, simplified:
|
||||
|
||||
| Mode | Name | Meaning |
|
||||
|------|--------------|---------|
|
||||
| `+i` | Invite-only | Only invited users can join |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||
| `+s` | Secret | Channel hidden from LIST response |
|
||||
| `+t` | Topic lock | Only operators can change the topic |
|
||||
| `+n` | No external | Only channel members can send messages to the channel |
|
||||
| Mode | Name | Meaning |
|
||||
|------|----------------|---------|
|
||||
| `+i` | Invite-only | Only invited users can join |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||
| `+s` | Secret | Channel hidden from LIST response |
|
||||
| `+t` | Topic lock | Only operators can change the topic |
|
||||
| `+n` | No external | Only channel members can send messages to the channel |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
||||
|
||||
**User channel modes (set per-user per-channel):**
|
||||
|
||||
@@ -1029,6 +1030,56 @@ Inspired by IRC, simplified:
|
||||
|
||||
**Status:** Channel modes are defined but not yet enforced. The `modes` column
|
||||
exists in the channels table but the server does not check modes on actions.
|
||||
Exception: `+H` (hashcash) is fully enforced — see below.
|
||||
|
||||
### Per-Channel Hashcash (Anti-Spam)
|
||||
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||
compute a proof-of-work stamp bound to the specific channel and message before
|
||||
sending.
|
||||
|
||||
**Setting the requirement:**
|
||||
|
||||
```
|
||||
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
|
||||
MODE #channel -H — disable hashcash requirement
|
||||
```
|
||||
|
||||
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
|
||||
|
||||
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
|
||||
- `YYMMDD` — current date (prevents old token reuse)
|
||||
- `channel` — channel name (prevents cross-channel reuse)
|
||||
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
|
||||
- `counter` — hex nonce
|
||||
|
||||
**Sending a message to a hashcash-protected channel:**
|
||||
|
||||
Include the hashcash stamp in the `meta` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "PRIVMSG",
|
||||
"to": "#general",
|
||||
"body": ["hello world"],
|
||||
"meta": {
|
||||
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||
the required difficulty, is bound to the correct channel and message body, has a
|
||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||
year to prevent replay attacks.
|
||||
|
||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||
descriptive reason.
|
||||
|
||||
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
|
||||
to compute stamps. Higher bit counts take exponentially longer to compute.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user