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:
@@ -22,6 +22,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"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/middleware"
|
||||
@@ -2157,3 +2158,397 @@ func TestNickBroadcastToChannels(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Channel Hashcash Tests ---
|
||||
|
||||
const (
|
||||
metaKey = "meta"
|
||||
modeCmd = "MODE"
|
||||
hashcashKey = "hashcash"
|
||||
)
|
||||
|
||||
func mintTestChannelHashcash(
|
||||
tb testing.TB,
|
||||
bits int,
|
||||
channel string,
|
||||
body json.RawMessage,
|
||||
) string {
|
||||
tb.Helper()
|
||||
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(bits, channel, bodyHash)
|
||||
}
|
||||
|
||||
func TestChannelHashcashSetMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcmode_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hctest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set hashcash bits to 2 via MODE +H.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hctest",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get RPL_CHANNELMODEIS (324) confirming +H.
|
||||
if !findNumeric(msgs, "324") {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashQueryMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcquery_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcquery",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Query mode — should show +nH.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
found := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == 324 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashClearMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcclear_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcclear",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
// Clear hashcash bits.
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"-H"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
// Now message should succeed without hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"test message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreject_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreject",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Send message without hashcash — should fail.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"spam message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcaccept_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcaccept",
|
||||
})
|
||||
|
||||
// Set hashcash requirement (2 bits = fast to mint).
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint a valid hashcash stamp.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"hello world"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcaccept", msgBody,
|
||||
)
|
||||
|
||||
// Send message with valid hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"hello world"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id for valid hashcash")
|
||||
}
|
||||
|
||||
// Verify the message was delivered.
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
|
||||
t.Fatalf(
|
||||
"message not received: %v", msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreplay_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreplay",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, _ = tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint and send once — should succeed.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"unique msg"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcreplay", msgBody,
|
||||
)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Replay the same stamp — should fail.
|
||||
status, _ = tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected replay rejection (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnone_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#nohashcash",
|
||||
})
|
||||
|
||||
// No hashcash set — message should work.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#nohashcash",
|
||||
bodyKey: []string{"free message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcbits_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcbits",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set bits to 0 — should fail.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcbits",
|
||||
bodyKey: []string{"+H", "0"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "472") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNMODE (472), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnoarg_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcnoarg",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set +H without bits argument.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcnoarg",
|
||||
bodyKey: []string{"+H"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user