feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 2m18s
All checks were successful
check / check (push) Successful in 2m18s
Add per-channel hashcash requirement via MODE +H <bits>. When set, PRIVMSG to the channel must include a valid hashcash stamp in the meta.hashcash field bound to the channel name and message body hash. Server validates stamp format, difficulty, date freshness, channel binding, body hash binding, and proof-of-work. Spent stamps are persisted to SQLite with 1-year TTL for replay prevention. Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter Changes: - Schema: add hashcash_bits column to channels, spent_hashcash table - DB: queries for get/set channel hashcash bits, spent token CRUD - Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp - Handlers: validate hashcash on PRIVMSG, MODE +H/-H support - Pass meta through fanOut chain to store in messages - Prune spent hashcash tokens in cleanup loop (1-year TTL) - Client: MintChannelHashcash helper for CLI - Tests: 12 new channel_test.go + 10 new api_test.go integration tests - README: document +H mode, stamp format, and usage
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"
|
||||
@@ -1988,3 +1989,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