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:
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -11,10 +12,16 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
errHashcashRequired = errors.New("hashcash required")
|
||||
errHashcashReused = errors.New("hashcash reused")
|
||||
)
|
||||
|
||||
var validNickRe = regexp.MustCompile(
|
||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||
)
|
||||
@@ -88,10 +95,11 @@ func (hdlr *Handlers) fanOut(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (string, error) {
|
||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||
request.Context(), command, from, target, nil, body, nil,
|
||||
request.Context(), command, from, target, nil, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert message: %w", err)
|
||||
@@ -117,10 +125,11 @@ func (hdlr *Handlers) fanOutSilent(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) error {
|
||||
_, err := hdlr.fanOut(
|
||||
request, command, from, target, body, sessionIDs,
|
||||
request, command, from, target, body, meta, sessionIDs,
|
||||
)
|
||||
|
||||
return err
|
||||
@@ -291,7 +300,7 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
[]string{
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"CHANMODES=,,," + "imnst",
|
||||
"CHANMODES=,H,," + "imnst",
|
||||
"NETWORK=neoirc",
|
||||
"CASEMAPPING=ascii",
|
||||
},
|
||||
@@ -822,7 +831,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
payload.Command, payload.To,
|
||||
payload.Body, bodyLines,
|
||||
payload.Body, payload.Meta, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -833,6 +842,7 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
@@ -845,7 +855,7 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
hdlr.handlePrivmsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, bodyLines,
|
||||
command, target, body, meta, bodyLines,
|
||||
)
|
||||
case irc.CmdJoin:
|
||||
hdlr.handleJoin(
|
||||
@@ -946,6 +956,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
@@ -981,7 +992,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
hdlr.handleChannelMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body,
|
||||
command, target, body, meta,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -990,7 +1001,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
hdlr.handleDirectMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body,
|
||||
command, target, body, meta,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1021,6 +1032,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
request.Context(), target,
|
||||
@@ -1061,9 +1073,172 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.sendChannelMsg(
|
||||
writer, request, command, nick, target, body, chID,
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.sendChannelMsg(
|
||||
writer, request, command, nick, target,
|
||||
body, meta, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// validateChannelHashcash checks whether the channel
|
||||
// requires hashcash proof-of-work for messages and
|
||||
// validates the stamp from the message meta field.
|
||||
// Returns nil on success or if the channel has no
|
||||
// hashcash requirement. On failure, it sends the
|
||||
// appropriate IRC error and returns a non-nil error.
|
||||
func (hdlr *Handlers) validateChannelHashcash(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
writer http.ResponseWriter,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
|
||||
ctx, chID,
|
||||
)
|
||||
if bitsErr != nil {
|
||||
hdlr.log.Error(
|
||||
"get channel hashcash bits", "error", bitsErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
|
||||
}
|
||||
|
||||
if bits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stamp := hdlr.extractHashcashFromMeta(meta)
|
||||
if stamp == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Channel requires hashcash proof-of-work",
|
||||
)
|
||||
|
||||
return errHashcashRequired
|
||||
}
|
||||
|
||||
return hdlr.verifyChannelStamp(
|
||||
request, writer,
|
||||
clientID, sessionID,
|
||||
nick, target, body, stamp, bits,
|
||||
)
|
||||
}
|
||||
|
||||
// verifyChannelStamp validates a channel hashcash stamp
|
||||
// and checks for replay attacks.
|
||||
func (hdlr *Handlers) verifyChannelStamp(
|
||||
request *http.Request,
|
||||
writer http.ResponseWriter,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
stamp string,
|
||||
bits int,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
bodyHashStr := hashcash.BodyHash(body)
|
||||
|
||||
valErr := hdlr.channelHashcash.ValidateStamp(
|
||||
stamp, bits, target, bodyHashStr,
|
||||
)
|
||||
if valErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Invalid hashcash: "+valErr.Error(),
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash: %w", valErr)
|
||||
}
|
||||
|
||||
stampKey := hashcash.StampHash(stamp)
|
||||
|
||||
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if spentErr != nil {
|
||||
hdlr.log.Error(
|
||||
"check spent hashcash", "error", spentErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("check spent hashcash: %w", spentErr)
|
||||
}
|
||||
|
||||
if spent {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Hashcash stamp already used",
|
||||
)
|
||||
|
||||
return errHashcashReused
|
||||
}
|
||||
|
||||
recordErr := hdlr.params.Database.RecordSpentHashcash(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if recordErr != nil {
|
||||
hdlr.log.Error(
|
||||
"record spent hashcash", "error", recordErr,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHashcashFromMeta parses the meta JSON and
|
||||
// returns the hashcash stamp string, or empty string
|
||||
// if not present.
|
||||
func (hdlr *Handlers) extractHashcashFromMeta(
|
||||
meta json.RawMessage,
|
||||
) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var metaMap map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(meta, &metaMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw, ok := metaMap["hashcash"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var stamp string
|
||||
|
||||
err = json.Unmarshal(raw, &stamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stamp
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) sendChannelMsg(
|
||||
@@ -1071,6 +1246,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
||||
request *http.Request,
|
||||
command, nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||
@@ -1090,7 +1266,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, memberIDs,
|
||||
request, command, nick, target, body, meta, memberIDs,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send message failed", "error", err)
|
||||
@@ -1114,6 +1290,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||
request.Context(), target,
|
||||
@@ -1138,7 +1315,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, recipients,
|
||||
request, command, nick, target, body, meta, recipients,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send dm failed", "error", err)
|
||||
@@ -1249,7 +1426,7 @@ func (hdlr *Handlers) executeJoin(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.deliverJoinNumerics(
|
||||
@@ -1419,7 +1596,7 @@ func (hdlr *Handlers) handlePart(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
err = hdlr.params.Database.PartChannel(
|
||||
@@ -1673,7 +1850,7 @@ func (hdlr *Handlers) executeTopic(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
@@ -1836,11 +2013,10 @@ func (hdlr *Handlers) handleMode(
|
||||
return
|
||||
}
|
||||
|
||||
_ = bodyLines
|
||||
|
||||
hdlr.handleChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel,
|
||||
bodyLines,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1849,6 +2025,7 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
@@ -1865,10 +2042,47 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) > 0 {
|
||||
hdlr.applyChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, lines,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.queryChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||
// RPL_CREATIONTIME for a channel. Includes +H if
|
||||
// the channel has a hashcash requirement.
|
||||
func (hdlr *Handlers) queryChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
modeStr := "+n"
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modeStr = fmt.Sprintf("+nH %d", bits)
|
||||
}
|
||||
|
||||
// 324 RPL_CHANNELMODEIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
[]string{channel, modeStr}, "",
|
||||
)
|
||||
|
||||
// 329 RPL_CREATIONTIME
|
||||
@@ -1893,6 +2107,156 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// applyChannelMode handles setting channel modes.
|
||||
// Currently supports +H/-H for hashcash bits.
|
||||
func (hdlr *Handlers) applyChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
modeStr := modeArgs[0]
|
||||
|
||||
switch modeStr {
|
||||
case "+H":
|
||||
hdlr.setHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs,
|
||||
)
|
||||
case "-H":
|
||||
hdlr.clearHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID,
|
||||
)
|
||||
default:
|
||||
// Unknown or unsupported mode change.
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.ErrUnknownMode, nick,
|
||||
[]string{modeStr},
|
||||
"is unknown mode char to me",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "error"},
|
||||
http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// minHashcashBits is the minimum allowed hashcash
|
||||
// difficulty for channels.
|
||||
minHashcashBits = 1
|
||||
// maxHashcashBits is the maximum allowed hashcash
|
||||
// difficulty for channels.
|
||||
maxHashcashBits = 40
|
||||
)
|
||||
|
||||
// setHashcashMode handles MODE #channel +H <bits>.
|
||||
func (hdlr *Handlers) setHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||
"Not enough parameters (+H requires bits)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bits, err := strconv.Atoi(modeArgs[1])
|
||||
if err != nil || bits < minHashcashBits ||
|
||||
bits > maxHashcashBits {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUnknownMode, nick, []string{"+H"},
|
||||
fmt.Sprintf(
|
||||
"Invalid hashcash bits (must be %d-%d)",
|
||||
minHashcashBits, maxHashcashBits,
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, bits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{
|
||||
channel,
|
||||
fmt.Sprintf("+H %d", bits),
|
||||
}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// clearHashcashMode handles MODE #channel -H.
|
||||
func (hdlr *Handlers) clearHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
err := hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, 0,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"clear channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleNames sends NAMES reply for a channel.
|
||||
func (hdlr *Handlers) handleNames(
|
||||
writer http.ResponseWriter,
|
||||
|
||||
Reference in New Issue
Block a user