Enforce channel keys, invites, +s and +H on the IRC listener (closes #122) #139

Merged
clawbot merged 1 commits from issue-122-irc-channel-modes into next 2026-10-20 16:02:14 +02:00
7 changed files with 494 additions and 450 deletions
Showing only changes of commit 63d87a372e - Show all commits
+28 -27
View File
@@ -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:**
-51
View File
@@ -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(
+6 -291
View File
@@ -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
}
+17 -19
View File
@@ -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: &params,
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: &params,
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{
+28 -39
View File
@@ -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
}
+210
View File
@@ -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.
+205 -23
View File
@@ -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
}