diff --git a/README.md b/README.md index 1d68680..a754b7c 100644 --- a/README.md +++ b/README.md @@ -1277,17 +1277,17 @@ Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on the IRC listener. -| Mode | Name | Meaning | Status | -| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced**; only an invite made over the HTTP API lets a user in | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only | +| Mode | Name | Meaning | Status | +| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | +| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced** | +| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | +| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS`; not by `GET /api/v1/channels` | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp | **User channel modes (set per-user per-channel):** @@ -1312,10 +1312,9 @@ bans. Bans prevent both joining and sending messages. These ban all users from example.com, remove that ban, and list all bans (RPL_BANLIST 367, RPL_ENDOFBANLIST 368). -**Invite-only (+i):** When set, users must be invited before joining. Over the -HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can -invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener -tells the user about the invite but records none. +**Invite-only (+i):** When set, users must be invited before joining. `INVITE` +records an invite that is consumed on JOIN; any member can invite, and on a `+i` +channel only an operator can. ```json {"command": "MODE", "to": "#channel", "body": ["+i"]} @@ -1323,7 +1322,9 @@ tells the user about the invite but records none. ``` **Channel key (+k):** Requires a password to join the channel. Over the HTTP API -the key is `body[0]` of the `JOIN`. +the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel +name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each +channel its own key. ```json {"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]} @@ -1338,9 +1339,8 @@ the key is `body[0]` of the `JOIN`. {"command": "MODE", "to": "#channel", "body": ["-l"]} ``` -**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and -its `WHOIS` leaves it out of the channel list when the querier is not in the -same channel. +**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves +it out of the channel list when the querier is not in the same channel. **Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong @@ -1351,10 +1351,10 @@ key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL `KICK #channel nick [:reason]`. The kicked user and all channel members receive the KICK message. -**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check -on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401, -403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a -user who is away gets RPL_AWAY there. +**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the +same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it +gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is +away, as it does for PRIVMSG. **ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with `CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC @@ -1362,10 +1362,11 @@ listener. ### Per-Channel Hashcash (Anti-Spam) -Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the -HTTP API; the IRC listener does not check it. 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. +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. An IRC listener client has no way to attach a stamp, so its `PRIVMSG` +to such a channel always gets ERR_CANNOTSENDTOCHAN (404). **Setting the requirement:** diff --git a/internal/db/queries.go b/internal/db/queries.go index b50a2eb..a552d95 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1482,57 +1482,6 @@ type ChannelInfoFull struct { MemberCount int64 `json:"memberCount"` } -// ListAllChannelsWithCounts returns every channel -// with its member count. -func (database *Database) ListAllChannelsWithCounts( - ctx context.Context, -) ([]ChannelInfoFull, error) { - rows, err := database.conn.QueryContext(ctx, - `SELECT c.id, c.name, c.topic, - COUNT(cm.session_id) AS member_count - FROM channels c - LEFT JOIN channel_members cm - ON cm.channel_id = c.id - GROUP BY c.id - ORDER BY c.name`) - if err != nil { - return nil, fmt.Errorf( - "list channels with counts: %w", err, - ) - } - - defer func() { _ = rows.Close() }() - - var out []ChannelInfoFull - - for rows.Next() { - var chanInfo ChannelInfoFull - - err = rows.Scan( - &chanInfo.ID, &chanInfo.Name, - &chanInfo.Topic, &chanInfo.MemberCount, - ) - if err != nil { - return nil, fmt.Errorf( - "scan channel full: %w", err, - ) - } - - out = append(out, chanInfo) - } - - err = rows.Err() - if err != nil { - return nil, fmt.Errorf("rows error: %w", err) - } - - if out == nil { - out = []ChannelInfoFull{} - } - - return out, nil -} - // GetChannelCreatedAt returns the creation time of a // channel. func (database *Database) GetChannelCreatedAt( diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 76da66f..8ca7480 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -15,16 +15,10 @@ import ( "github.com/go-chi/chi/v5" "sneak.berlin/go/neoirc/internal/db" - "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/pkg/irc" ) -var ( - errHashcashRequired = errors.New("hashcash required") - errHashcashReused = errors.New("hashcash reused") -) - var validNickRe = regexp.MustCompile( `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, ) @@ -1231,23 +1225,6 @@ func (hdlr *Handlers) handleChannelMsg( ) { ctx := request.Context() - // Hashcash validation is HTTP-specific; needs chID. - isNotice := command == irc.CmdNotice - - if !isNotice { - chID, chErr := hdlr.params.Database. - GetChannelByName(ctx, target) - if chErr == nil { - hashcashErr := hdlr.validateChannelHashcash( - request, clientID, sessionID, - writer, nick, target, body, meta, chID, - ) - if hashcashErr != nil { - return - } - } - } - // Delegate validation + fan-out to service layer. dbID, uuid, err := hdlr.svc.SendChannelMessage( ctx, sessionID, nick, @@ -1272,160 +1249,6 @@ func (hdlr *Handlers) handleChannelMsg( http.StatusOK) } -// 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) handleDirectMsg( writer http.ResponseWriter, request *http.Request, @@ -2914,123 +2737,15 @@ func (hdlr *Handlers) handleInvite( channel = "#" + channel } - chID, targetSID, ok := hdlr.validateInvite( - writer, request, - sessionID, clientID, nick, - targetNick, channel, - ) - if !ok { - return - } - - hdlr.executeInvite( - writer, request, - sessionID, clientID, nick, - targetNick, channel, chID, targetSID, - ) -} - -// validateInvite checks channel, membership, permissions, -// and target for an INVITE command. -func (hdlr *Handlers) validateInvite( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, targetNick, channel string, -) (int64, int64, bool) { ctx := request.Context() - chID, err := hdlr.params.Database.GetChannelByName( - ctx, channel, + targetSID, err := hdlr.svc.InviteUser( + ctx, sessionID, nick, targetNick, channel, ) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchChannel, nick, - []string{channel}, "No such channel", - ) - - return 0, 0, false - } - - isMember, memErr := hdlr.params.Database.IsChannelMember( - ctx, chID, sessionID, - ) - if memErr != nil || !isMember { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNotOnChannel, nick, - []string{channel}, - "You're not on that channel", - ) - - return 0, 0, false - } - - isInviteOnly, ioErr := hdlr.params.Database. - IsChannelInviteOnly(ctx, chID) - if ioErr == nil && isInviteOnly { - if !hdlr.requireChannelOp( - writer, request, - sessionID, clientID, nick, channel, chID, - ) { - return 0, 0, false - } - } - - targetSID, nickErr := hdlr.params.Database. - GetSessionByNick(ctx, targetNick) - if nickErr != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchNick, nick, - []string{targetNick}, - "No such nick/channel", - ) - - return 0, 0, false - } - - alreadyIn, aiErr := hdlr.params.Database. - IsChannelMember(ctx, chID, targetSID) - if aiErr == nil && alreadyIn { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUserOnChannel, nick, - []string{targetNick, channel}, - "is already on channel", - ) - - return 0, 0, false - } - - return chID, targetSID, true -} - -// executeInvite records the invite and sends -// notifications. -func (hdlr *Handlers) executeInvite( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, targetNick, channel string, - chID, targetSID int64, -) { - ctx := request.Context() - - invErr := hdlr.params.Database.AddChannelInvite( - ctx, chID, targetSID, nick, - ) - if invErr != nil { - hdlr.log.Error( - "add invite failed", "error", invErr, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a150cc8..690b045 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -55,16 +55,15 @@ 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 - svc *service.Service - hashcashVal *hashcash.Validator - channelHashcash *hashcash.ChannelValidator - loginLimiter *ratelimit.Limiter - stats *stats.Tracker - cancelCleanup context.CancelFunc + params *Params + log *slog.Logger + hc *healthcheck.Healthcheck + broker *broker.Broker + svc *service.Service + hashcashVal *hashcash.Validator + loginLimiter *ratelimit.Limiter + stats *stats.Tracker + cancelCleanup context.CancelFunc } // New creates a new Handlers instance. @@ -88,15 +87,14 @@ func New( } hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup - params: ¶ms, - log: params.Logger.Get(), - hc: params.Healthcheck, - broker: params.Broker, - svc: params.Service, - hashcashVal: hashcash.NewValidator(resource), - channelHashcash: hashcash.NewChannelValidator(), - loginLimiter: ratelimit.New(loginRate, loginBurst), - stats: params.Stats, + params: ¶ms, + log: params.Logger.Get(), + hc: params.Healthcheck, + broker: params.Broker, + svc: params.Service, + hashcashVal: hashcash.NewValidator(resource), + loginLimiter: ratelimit.New(loginRate, loginBurst), + stats: params.Stats, } lifecycle.Append(fx.Hook{ diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 40d83a9..fb2e8c5 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -150,7 +150,9 @@ func (c *Conn) handlePrivmsg( } // handleJoin joins one or more channels via the shared -// service layer. +// service layer. The optional second parameter holds the +// channel keys, comma-separated in the order of the +// channels. func (c *Conn) handleJoin( ctx context.Context, msg *Message, @@ -164,14 +166,24 @@ func (c *Conn) handleJoin( return } - for chanName := range strings.SplitSeq(msg.Params[0], ",") { + var keys []string + if len(msg.Params) > 1 { + keys = strings.Split(msg.Params[1], ",") + } + + for idx, chanName := range strings.Split(msg.Params[0], ",") { chanName = strings.TrimSpace(chanName) if !strings.HasPrefix(chanName, "#") { chanName = "#" + chanName } - c.joinChannel(ctx, chanName) + key := "" + if idx < len(keys) { + key = keys[idx] + } + + c.joinChannel(ctx, chanName, key) } } @@ -179,10 +191,10 @@ func (c *Conn) handleJoin( // and delivers topic/names on the wire. func (c *Conn) joinChannel( ctx context.Context, - channel string, + channel, key string, ) { result, err := c.svc.JoinChannel( - ctx, c.sessionID, c.nick, channel, "", + ctx, c.sessionID, c.nick, channel, key, ) if err != nil { c.sendIRCError(err) @@ -763,10 +775,11 @@ func (c *Conn) handleNames( c.deliverNames(ctx, channel, chID) } -// handleList sends the channel list. +// handleList sends the channel list, leaving out +s +// channels the user is not on. func (c *Conn) handleList(ctx context.Context) { - channels, err := c.database.ListAllChannelsWithCounts( - ctx, + channels, err := c.database.ListAllChannelsWithCountsFiltered( + ctx, c.sessionID, ) if err != nil { c.sendNumeric( @@ -886,14 +899,15 @@ func (c *Conn) whoisOper( } } -// whoisChannels sends 319 RPL_WHOISCHANNELS. +// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out +// +s channels the querier is not on. func (c *Conn) whoisChannels( ctx context.Context, target string, targetID int64, ) { - userChannels, _ := c.database.GetSessionChannels( - ctx, targetID, + userChannels, _ := c.database.GetSessionChannelsFiltered( + ctx, targetID, c.sessionID, ) if len(userChannels) == 0 { return @@ -1230,36 +1244,11 @@ func (c *Conn) handleInvite( targetNick := msg.Params[0] channel := msg.Params[1] - chID, err := c.database.GetChannelByName(ctx, channel) - if err != nil { - c.sendNumeric( - irc.ErrNoSuchChannel, - channel, "No such channel", - ) - - return - } - - isMember, _ := c.database.IsChannelMember( - ctx, chID, c.sessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrNotOnChannel, - channel, "You're not on that channel", - ) - - return - } - - targetID, err := c.database.GetSessionByNick( - ctx, targetNick, + targetID, err := c.svc.InviteUser( + ctx, c.sessionID, c.nick, targetNick, channel, ) if err != nil { - c.sendNumeric( - irc.ErrNoSuchNick, - targetNick, "No such nick", - ) + c.sendIRCError(err) return } diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index adb69f4..3646832 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -1239,6 +1239,216 @@ func TestIntegrationModeModerated(t *testing.T) { ) } +// TestIntegrationModeKey checks that JOIN takes the key of +// a +k channel as its second parameter, and that with +// several channels each key goes to the channel in the +// same position. +func TestIntegrationModeKey(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#keyed") + alice.joinAndDrain("#vault") + + // The IRC listener cannot set +k, so the test sets the + // keys directly. + for channel, key := range map[string]string{ + "#keyed": "sesame", + "#vault": "barley", + } { + chID, err := env.database.GetChannelByName( + t.Context(), channel, + ) + if err != nil { + t.Fatalf("get channel %s: %v", channel, err) + } + + err = env.database.SetChannelKey(t.Context(), chID, key) + if err != nil { + t.Fatalf("set key of %s: %v", channel, err) + } + } + + bob := env.dial(t) + bob.register("bob") + + bob.sendAndExpect("JOIN #keyed", " 475 ") + bob.sendAndExpect("JOIN #keyed wrong", " 475 ") + + lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ") + assertContains( + t, lines, "JOIN #keyed", "bob joins with the key", + ) + + carol := env.dial(t) + carol.register("carol") + + lines = carol.sendAndExpect( + "JOIN #keyed,#vault sesame,barley", " 366 carol #vault ", + ) + assertContains(t, lines, "JOIN #keyed", "carol joins #keyed") + assertContains(t, lines, "JOIN #vault", "carol joins #vault") + assertNotContains(t, lines, " 475 ", "carol's JOIN") +} + +// TestIntegrationModeInviteOnly checks that INVITE lets a +// user into a +i channel. +func TestIntegrationModeInviteOnly(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#invonly") + alice.sendAndExpect("MODE #invonly +i", "+i") + + bob := env.dial(t) + bob.register("bob") + + bob.sendAndExpect("JOIN #invonly", " 473 ") + + alice.sendAndExpect("INVITE bob #invonly", " 341 ") + + lines := bob.sendAndExpect("JOIN #invonly", " 366 ") + assertContains( + t, lines, "JOIN #invonly", "bob joins once invited", + ) +} + +// TestIntegrationModeInviteOnlyNeedsOperator checks that on +// a +i channel an INVITE from a member who is not a channel +// operator gets 482 and does not let the invited user in. +func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#invonly") + + // Bob joins before +i is set, so he is a member but + // not an operator. + bob := env.dial(t) + bob.register("bob") + bob.joinAndDrain("#invonly") + + alice.sendAndExpect("MODE #invonly +i", "+i") + + carol := env.dial(t) + carol.register("carol") + + lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ") + assertContains( + t, lines, " 482 bob #invonly ", "bob's INVITE refused", + ) + + carol.sendAndExpect("JOIN #invonly", " 473 ") +} + +// TestIntegrationInviteUserOnChannel checks that inviting a +// user who is already on the channel gets 443. +func TestIntegrationInviteUserOnChannel(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#room") + + bob := env.dial(t) + bob.register("bob") + bob.joinAndDrain("#room") + + lines := alice.sendAndExpect("INVITE bob #room", " 443 ") + assertContains( + t, lines, " 443 alice bob #room ", "alice's INVITE refused", + ) +} + +// TestIntegrationModeSecretList checks that LIST shows a +s +// channel only to its members. +func TestIntegrationModeSecretList(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#hidden") + alice.joinAndDrain("#visible") + alice.sendAndExpect("MODE #hidden +s", "+s") + + bob := env.dial(t) + bob.register("bob") + + bobList := bob.sendAndExpect("LIST", " 323 ") + assertContains( + t, bobList, " 322 bob #visible ", "bob's LIST", + ) + assertNotContains(t, bobList, "#hidden", "bob's LIST") + + aliceList := alice.sendAndExpect("LIST", " 323 ") + assertContains( + t, aliceList, " 322 alice #hidden ", "alice's LIST", + ) +} + +// TestIntegrationModeSecretWhois checks that WHOIS names a +// +s channel only to users on it. +func TestIntegrationModeSecretWhois(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#hidden") + alice.joinAndDrain("#visible") + alice.sendAndExpect("MODE #hidden +s", "+s") + + bob := env.dial(t) + bob.register("bob") + + bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ") + assertContains(t, bobWhois, "@#visible", "bob's WHOIS") + assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS") + + carol := env.dial(t) + carol.register("carol") + carol.joinAndDrain("#hidden") + + carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ") + assertContains(t, carolWhois, "@#hidden", "carol's WHOIS") +} + +// TestIntegrationModeHashcash checks that PRIVMSG to a +H +// channel is refused, since an IRC client cannot attach a +// hashcash stamp. +func TestIntegrationModeHashcash(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#stamped") + alice.sendAndExpect("MODE #stamped +H 2", "+H") + + lines := alice.sendAndExpect( + "PRIVMSG #stamped :hello", " 404 ", + ) + assertContains( + t, lines, " 404 alice #stamped :Channel requires hashcash", + "PRIVMSG refused", + ) +} + // TestIntegrationThirdClientObserver verifies that a third // client observing the same channel receives messages from // the other two. diff --git a/internal/service/service.go b/internal/service/service.go index d6edb06..66206a8 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -21,6 +21,7 @@ import ( "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" + "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/pkg/irc" ) @@ -30,6 +31,7 @@ const ( msgNoSuchChannel = "No such channel" msgNoSuchNick = "No such nick/channel" msgNotChannelOp = "You're not channel operator" + msgNotOnChannel = "You're not on that channel" ) // maxUserhostNicks is how many nicks one USERHOST answers @@ -63,11 +65,12 @@ type WireConn interface { // Service provides shared business logic for IRC commands. type Service struct { - db *db.Database - broker *broker.Broker - config *config.Config - globals *globals.Globals - log *slog.Logger + db *db.Database + broker *broker.Broker + config *config.Config + globals *globals.Globals + log *slog.Logger + channelHashcash *hashcash.ChannelValidator wireMu sync.Mutex wireConns map[int64]WireConn @@ -76,12 +79,13 @@ type Service struct { // New creates a new Service. func New(params Params) *Service { return &Service{ - db: params.Database, - broker: params.Broker, - config: params.Config, - globals: params.Globals, - log: params.Logger.Get(), - wireConns: make(map[int64]WireConn), + db: params.Database, + broker: params.Broker, + config: params.Config, + globals: params.Globals, + log: params.Logger.Get(), + channelHashcash: hashcash.NewChannelValidator(), + wireConns: make(map[int64]WireConn), } } @@ -95,12 +99,13 @@ func NewTestService( log *slog.Logger, ) *Service { return &Service{ - db: database, - broker: brk, - config: cfg, - globals: globs, - log: log, - wireConns: make(map[int64]WireConn), + db: database, + broker: brk, + config: cfg, + globals: globs, + log: log, + channelHashcash: hashcash.NewChannelValidator(), + wireConns: make(map[int64]WireConn), } } @@ -366,10 +371,11 @@ func excludeSession( return out } -// SendChannelMessage validates membership and moderation, -// then fans out a message to all channel members except -// the sender. Returns the database row ID, message UUID, -// and any error. The dbID lets callers enqueue the same +// SendChannelMessage validates the hashcash stamp of a +// PRIVMSG to a +H channel, membership and moderation, then +// fans out a message to all channel members except the +// sender. Returns the database row ID, message UUID, and +// any error. The dbID lets callers enqueue the same // message to the sender when echo is needed (HTTP // transport). func (s *Service) SendChannelMessage( @@ -387,6 +393,15 @@ func (s *Service) SendChannelMessage( } } + if command == irc.CmdPrivmsg { + err = s.checkChannelHashcash( + ctx, chID, channel, body, meta, + ) + if err != nil { + return 0, "", err + } + } + isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) @@ -568,7 +583,7 @@ func (s *Service) PartChannel( return &IRCError{ irc.ErrNotOnChannel, []string{channel}, - "You're not on that channel", + msgNotOnChannel, } } @@ -610,7 +625,7 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrNotOnChannel, []string{channel}, - "You're not on that channel", + msgNotOnChannel, } } @@ -713,6 +728,78 @@ func (s *Service) KickUser( return nil } +// InviteUser records an invite of targetNick to channel, +// which JoinChannel consumes, and returns the target's +// session ID. The inviter must be on the channel, and an +// operator if it is +i; the target must exist and not be +// on the channel already. +func (s *Service) InviteUser( + ctx context.Context, + sessionID int64, + nick, targetNick, channel string, +) (int64, error) { + chID, err := s.db.GetChannelByName(ctx, channel) + if err != nil { + return 0, &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + msgNoSuchChannel, + } + } + + isMember, memErr := s.db.IsChannelMember( + ctx, chID, sessionID, + ) + if memErr != nil || !isMember { + return 0, &IRCError{ + irc.ErrNotOnChannel, + []string{channel}, + msgNotOnChannel, + } + } + + isInviteOnly, ioErr := s.db.IsChannelInviteOnly(ctx, chID) + if ioErr == nil && isInviteOnly { + isOp, opErr := s.db.IsChannelOperator( + ctx, chID, sessionID, + ) + if opErr != nil || !isOp { + return 0, &IRCError{ + irc.ErrChanOpPrivsNeeded, + []string{channel}, + msgNotChannelOp, + } + } + } + + targetSID, err := s.db.GetSessionByNick(ctx, targetNick) + if err != nil { + return 0, &IRCError{ + irc.ErrNoSuchNick, + []string{targetNick}, + msgNoSuchNick, + } + } + + alreadyIn, aiErr := s.db.IsChannelMember( + ctx, chID, targetSID, + ) + if aiErr == nil && alreadyIn { + return 0, &IRCError{ + irc.ErrUserOnChannel, + []string{targetNick, channel}, + "is already on channel", + } + } + + err = s.db.AddChannelInvite(ctx, chID, targetSID, nick) + if err != nil { + return 0, fmt.Errorf("add invite: %w", err) + } + + return targetSID, nil +} + // ChangeNick changes a user's nickname and broadcasts the // change to all users sharing channels. func (s *Service) ChangeNick( @@ -1169,6 +1256,71 @@ func (s *Service) requireOper( return nil } +// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless +// the channel has no +H, or meta carries a hashcash stamp +// for this channel and body with enough bits that has not +// been used before. A stamp that passes is recorded as +// spent. +func (s *Service) checkChannelHashcash( + ctx context.Context, + chID int64, + channel string, + body, meta json.RawMessage, +) error { + bits, err := s.db.GetChannelHashcashBits(ctx, chID) + if err != nil { + return fmt.Errorf("channel hashcash bits: %w", err) + } + + if bits <= 0 { + return nil + } + + stamp := extractHashcashFromMeta(meta) + if stamp == "" { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Channel requires hashcash proof-of-work", + } + } + + err = s.channelHashcash.ValidateStamp( + stamp, bits, channel, hashcash.BodyHash(body), + ) + if err != nil { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Invalid hashcash: " + err.Error(), + } + } + + stampKey := hashcash.StampHash(stamp) + + spent, err := s.db.IsHashcashSpent(ctx, stampKey) + if err != nil { + return fmt.Errorf("check spent hashcash: %w", err) + } + + if spent { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Hashcash stamp already used", + } + } + + err = s.db.RecordSpentHashcash(ctx, stampKey) + if err != nil { + s.log.Error( + "record spent hashcash", "error", err, + ) + } + + return nil +} + // broadcastNickChange notifies channel peers of a nick // change. func (s *Service) broadcastNickChange( @@ -1277,3 +1429,33 @@ func checkJoinRestrictions( return nil } + +// extractHashcashFromMeta parses the meta JSON and +// returns the hashcash stamp string, or empty string +// if not present. +func 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 +}