diff --git a/README.md b/README.md index 2f48e23..627f15f 100644 --- a/README.md +++ b/README.md @@ -461,7 +461,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:** @@ -950,13 +950,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):** @@ -967,6 +968,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 — require 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. --- diff --git a/internal/cli/api/hashcash.go b/internal/cli/api/hashcash.go index c064007..0eac84c 100644 --- a/internal/cli/api/hashcash.go +++ b/internal/cli/api/hashcash.go @@ -37,6 +37,35 @@ func MintHashcash(bits int, resource string) string { } } +// 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. +func MintChannelHashcash( + bits int, + channel string, + body []byte, +) string { + bodyHash := sha256.Sum256(body) + bodyHashHex := hex.EncodeToString(bodyHash[:]) + date := time.Now().UTC().Format("060102") + prefix := fmt.Sprintf( + "1:%d:%s:%s:%s:", + bits, date, channel, bodyHashHex, + ) + + for { + counter := randomCounter() + stamp := prefix + counter + hash := sha256.Sum256([]byte(stamp)) + + if hasLeadingZeroBits(hash[:], bits) { + return stamp + } + } +} + // hasLeadingZeroBits checks if hash has at least numBits // leading zero bits. func hasLeadingZeroBits( diff --git a/internal/db/queries.go b/internal/db/queries.go index e97b1f0..e44df7e 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1266,3 +1266,110 @@ func (database *Database) PruneOldMessages( return deleted, nil } + +// GetChannelHashcashBits returns the hashcash difficulty +// requirement for a channel. Returns 0 if not set. +func (database *Database) GetChannelHashcashBits( + ctx context.Context, + channelID int64, +) (int, error) { + var bits int + + err := database.conn.QueryRowContext( + ctx, + "SELECT hashcash_bits FROM channels WHERE id = ?", + channelID, + ).Scan(&bits) + if err != nil { + return 0, fmt.Errorf( + "get channel hashcash bits: %w", err, + ) + } + + return bits, nil +} + +// SetChannelHashcashBits sets the hashcash difficulty +// requirement for a channel. A value of 0 disables the +// requirement. +func (database *Database) SetChannelHashcashBits( + ctx context.Context, + channelID int64, + bits int, +) error { + _, err := database.conn.ExecContext(ctx, + `UPDATE channels + SET hashcash_bits = ?, updated_at = ? + WHERE id = ?`, + bits, time.Now(), channelID) + if err != nil { + return fmt.Errorf( + "set channel hashcash bits: %w", err, + ) + } + + return nil +} + +// RecordSpentHashcash stores a spent hashcash stamp hash +// for replay prevention. +func (database *Database) RecordSpentHashcash( + ctx context.Context, + stampHash string, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO spent_hashcash + (stamp_hash, created_at) + VALUES (?, ?)`, + stampHash, time.Now()) + if err != nil { + return fmt.Errorf( + "record spent hashcash: %w", err, + ) + } + + return nil +} + +// IsHashcashSpent checks whether a hashcash stamp hash +// has already been used. +func (database *Database) IsHashcashSpent( + ctx context.Context, + stampHash string, +) (bool, error) { + var count int + + err := database.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM spent_hashcash + WHERE stamp_hash = ?`, + stampHash, + ).Scan(&count) + if err != nil { + return false, fmt.Errorf( + "check spent hashcash: %w", err, + ) + } + + return count > 0, nil +} + +// PruneSpentHashcash deletes spent hashcash tokens older +// than the cutoff and returns the number of rows removed. +func (database *Database) PruneSpentHashcash( + ctx context.Context, + cutoff time.Time, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + "DELETE FROM spent_hashcash WHERE created_at < ?", + cutoff, + ) + if err != nil { + return 0, fmt.Errorf( + "prune spent hashcash: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + return deleted, nil +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 4ea5e28..6b69eb4 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS channels ( topic TEXT NOT NULL DEFAULT '', topic_set_by TEXT NOT NULL DEFAULT '', topic_set_at DATETIME, + hashcash_bits INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -61,6 +62,14 @@ CREATE TABLE IF NOT EXISTS messages ( CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); +-- Spent hashcash tokens for replay prevention (1-year TTL) +CREATE TABLE IF NOT EXISTS spent_hashcash ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stamp_hash TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at); + -- Per-client message queues for fan-out delivery CREATE TABLE IF NOT EXISTS client_queues ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 74f9f9a..19723ee 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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 . +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, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index e4da9cb..4aeb26f 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -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, + ) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6a76ae3..00363e0 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -34,14 +34,20 @@ type Params struct { const defaultIdleTimeout = 30 * 24 * time.Hour +// spentHashcashTTL is how long spent hashcash tokens are +// retained for replay prevention. Per issue requirements, +// this is 1 year. +const spentHashcashTTL = 365 * 24 * time.Hour + // Handlers manages HTTP request handling. type Handlers struct { - params *Params - log *slog.Logger - hc *healthcheck.Healthcheck - broker *broker.Broker - hashcashVal *hashcash.Validator - cancelCleanup context.CancelFunc + params *Params + log *slog.Logger + hc *healthcheck.Healthcheck + broker *broker.Broker + hashcashVal *hashcash.Validator + channelHashcash *hashcash.ChannelValidator + cancelCleanup context.CancelFunc } // New creates a new Handlers instance. @@ -55,11 +61,12 @@ func New( } hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup - params: ¶ms, - log: params.Logger.Get(), - hc: params.Healthcheck, - broker: broker.New(), - hashcashVal: hashcash.NewValidator(resource), + params: ¶ms, + log: params.Logger.Get(), + hc: params.Healthcheck, + broker: broker.New(), + hashcashVal: hashcash.NewValidator(resource), + channelHashcash: hashcash.NewChannelValidator(), } lifecycle.Append(fx.Hook{ @@ -281,4 +288,20 @@ func (hdlr *Handlers) pruneQueuesAndMessages( ) } } + + // Prune spent hashcash tokens older than 1 year. + hashcashCutoff := time.Now().Add(-spentHashcashTTL) + + pruned, err := hdlr.params.Database. + PruneSpentHashcash(ctx, hashcashCutoff) + if err != nil { + hdlr.log.Error( + "spent hashcash pruning failed", "error", err, + ) + } else if pruned > 0 { + hdlr.log.Info( + "pruned spent hashcash tokens", + "deleted", pruned, + ) + } } diff --git a/internal/hashcash/channel.go b/internal/hashcash/channel.go new file mode 100644 index 0000000..4c9ba6a --- /dev/null +++ b/internal/hashcash/channel.go @@ -0,0 +1,186 @@ +package hashcash + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +var ( + errBodyHashMismatch = errors.New( + "body hash mismatch", + ) + errBodyHashMissing = errors.New( + "body hash missing", + ) +) + +// ChannelValidator checks hashcash stamps for +// per-channel PRIVMSG validation. It verifies that +// stamps are bound to a specific channel and message +// body. Replay prevention is handled externally via +// the database spent_hashcash table for persistence +// across server restarts (1-year TTL). +type ChannelValidator struct{} + +// NewChannelValidator creates a ChannelValidator. +func NewChannelValidator() *ChannelValidator { + return &ChannelValidator{} +} + +// BodyHash computes the hex-encoded SHA-256 hash of a +// message body for use in hashcash stamp validation. +func BodyHash(body []byte) string { + hash := sha256.Sum256(body) + + return hex.EncodeToString(hash[:]) +} + +// ValidateStamp checks a channel hashcash stamp. It +// verifies the stamp format, difficulty, date, channel +// binding, body hash binding, and proof-of-work. Replay +// detection is NOT performed here — callers must check +// the spent_hashcash table separately. +// +// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter. +func (cv *ChannelValidator) ValidateStamp( + stamp string, + requiredBits int, + channel string, + bodyHash string, +) error { + if requiredBits <= 0 { + return nil + } + + parts := strings.Split(stamp, ":") + if len(parts) != stampFields { + return fmt.Errorf( + "%w: expected %d, got %d", + errInvalidFields, stampFields, len(parts), + ) + } + + version := parts[0] + bitsStr := parts[1] + dateStr := parts[2] + resource := parts[3] + stampBodyHash := parts[4] + + headerErr := validateChannelHeader( + version, bitsStr, resource, + requiredBits, channel, + ) + if headerErr != nil { + return headerErr + } + + stampTime, parseErr := parseStampDate(dateStr) + if parseErr != nil { + return parseErr + } + + timeErr := validateTime(stampTime) + if timeErr != nil { + return timeErr + } + + bodyErr := validateBodyHash( + stampBodyHash, bodyHash, + ) + if bodyErr != nil { + return bodyErr + } + + return validateProof(stamp, requiredBits) +} + +// StampHash returns a deterministic hash of a stamp +// string for use as a spent-token key. +func StampHash(stamp string) string { + hash := sha256.Sum256([]byte(stamp)) + + return hex.EncodeToString(hash[:]) +} + +func validateChannelHeader( + version, bitsStr, resource string, + requiredBits int, + channel string, +) error { + if version != stampVersion { + return fmt.Errorf( + "%w: %s", errBadVersion, version, + ) + } + + claimedBits, err := strconv.Atoi(bitsStr) + if err != nil || claimedBits < requiredBits { + return fmt.Errorf( + "%w: need %d bits", + errInsufficientBits, requiredBits, + ) + } + + if resource != channel { + return fmt.Errorf( + "%w: got %q, want %q", + errWrongResource, resource, channel, + ) + } + + return nil +} + +func validateBodyHash( + stampBodyHash, expectedBodyHash string, +) error { + if stampBodyHash == "" { + return errBodyHashMissing + } + + if stampBodyHash != expectedBodyHash { + return fmt.Errorf( + "%w: got %q, want %q", + errBodyHashMismatch, + stampBodyHash, expectedBodyHash, + ) + } + + return nil +} + +// MintChannelStamp computes a channel hashcash stamp +// with the given difficulty, channel name, and body hash. +// This is intended for clients to generate stamps before +// sending PRIVMSG to hashcash-protected channels. +// +// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter. +func MintChannelStamp( + bits int, + channel string, + bodyHash string, +) string { + date := time.Now().UTC().Format(dateFormatShort) + prefix := fmt.Sprintf( + "1:%d:%s:%s:%s:", + bits, date, channel, bodyHash, + ) + + counter := uint64(0) + + for { + stamp := prefix + strconv.FormatUint(counter, 16) + hash := sha256.Sum256([]byte(stamp)) + + if hasLeadingZeroBits(hash[:], bits) { + return stamp + } + + counter++ + } +} diff --git a/internal/hashcash/channel_test.go b/internal/hashcash/channel_test.go new file mode 100644 index 0000000..638a481 --- /dev/null +++ b/internal/hashcash/channel_test.go @@ -0,0 +1,244 @@ +package hashcash_test + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "git.eeqj.de/sneak/neoirc/internal/hashcash" +) + +const ( + testChannel = "#general" + testBodyText = `["hello world"]` +) + +func testBodyHash() string { + hash := sha256.Sum256([]byte(testBodyText)) + + return hex.EncodeToString(hash[:]) +} + +func TestChannelValidateHappyPath(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + stamp := hashcash.MintChannelStamp( + testBits, testChannel, bodyHash, + ) + + err := validator.ValidateStamp( + stamp, testBits, testChannel, bodyHash, + ) + if err != nil { + t.Fatalf("valid channel stamp rejected: %v", err) + } +} + +func TestChannelValidateWrongChannel(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + stamp := hashcash.MintChannelStamp( + testBits, testChannel, bodyHash, + ) + + err := validator.ValidateStamp( + stamp, testBits, "#other", bodyHash, + ) + if err == nil { + t.Fatal("expected channel mismatch error") + } +} + +func TestChannelValidateWrongBodyHash(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + stamp := hashcash.MintChannelStamp( + testBits, testChannel, bodyHash, + ) + + wrongHash := sha256.Sum256([]byte("different body")) + wrongBodyHash := hex.EncodeToString(wrongHash[:]) + + err := validator.ValidateStamp( + stamp, testBits, testChannel, wrongBodyHash, + ) + if err == nil { + t.Fatal("expected body hash mismatch error") + } +} + +func TestChannelValidateInsufficientBits(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + // Mint with 2 bits but require 4. + stamp := hashcash.MintChannelStamp( + testBits, testChannel, bodyHash, + ) + + err := validator.ValidateStamp( + stamp, 4, testChannel, bodyHash, + ) + if err == nil { + t.Fatal("expected insufficient bits error") + } +} + +func TestChannelValidateZeroBitsSkips(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + + err := validator.ValidateStamp( + "garbage", 0, "#ch", "abc", + ) + if err != nil { + t.Fatalf("zero bits should skip: %v", err) + } +} + +func TestChannelValidateBadFormat(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + + err := validator.ValidateStamp( + "not:valid", testBits, testChannel, "abc", + ) + if err == nil { + t.Fatal("expected bad format error") + } +} + +func TestChannelValidateBadVersion(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + stamp := "2:2:260317:#general:" + bodyHash + ":counter" + + err := validator.ValidateStamp( + stamp, testBits, testChannel, bodyHash, + ) + if err == nil { + t.Fatal("expected bad version error") + } +} + +func TestChannelValidateExpiredStamp(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + // Mint with a very old date by manually constructing. + stamp := mintStampWithDate( + t, testBits, testChannel, "200101", + ) + + err := validator.ValidateStamp( + stamp, testBits, testChannel, bodyHash, + ) + if err == nil { + t.Fatal("expected expired stamp error") + } +} + +func TestChannelValidateMissingBodyHash(t *testing.T) { + t.Parallel() + + validator := hashcash.NewChannelValidator() + bodyHash := testBodyHash() + + // Construct a stamp with empty body hash field. + stamp := mintStampWithDate( + t, testBits, testChannel, todayDate(), + ) + + // This uses the session-style stamp which has empty + // ext field — body hash is missing. + err := validator.ValidateStamp( + stamp, testBits, testChannel, bodyHash, + ) + if err == nil { + t.Fatal("expected missing body hash error") + } +} + +func TestBodyHash(t *testing.T) { + t.Parallel() + + body := []byte(`["hello world"]`) + bodyHash := hashcash.BodyHash(body) + + if len(bodyHash) != 64 { + t.Fatalf( + "expected 64-char hex hash, got %d", + len(bodyHash), + ) + } + + // Same input should produce same hash. + bodyHash2 := hashcash.BodyHash(body) + if bodyHash != bodyHash2 { + t.Fatal("body hash not deterministic") + } + + // Different input should produce different hash. + bodyHash3 := hashcash.BodyHash([]byte("different")) + if bodyHash == bodyHash3 { + t.Fatal("different inputs produced same hash") + } +} + +func TestStampHash(t *testing.T) { + t.Parallel() + + hash1 := hashcash.StampHash("stamp1") + hash2 := hashcash.StampHash("stamp2") + + if hash1 == hash2 { + t.Fatal("different stamps produced same hash") + } + + // Same input should be deterministic. + hash1b := hashcash.StampHash("stamp1") + if hash1 != hash1b { + t.Fatal("stamp hash not deterministic") + } +} + +func TestMintChannelStamp(t *testing.T) { + t.Parallel() + + bodyHash := testBodyHash() + stamp := hashcash.MintChannelStamp( + testBits, testChannel, bodyHash, + ) + + if stamp == "" { + t.Fatal("expected non-empty stamp") + } + + // Validate the minted stamp. + validator := hashcash.NewChannelValidator() + + err := validator.ValidateStamp( + stamp, testBits, testChannel, bodyHash, + ) + if err != nil { + t.Fatalf("minted stamp failed validation: %v", err) + } +}