Enforce channel keys, invites, +s and +H on the IRC listener (closes #122)
check / check (push) Waiting to run

The IRC listener's JOIN now passes the channel key on, INVITE records
the invite, LIST and WHOIS hide +s channels from non-members with the
queries the HTTP API uses, and PRIVMSG to a +H channel gets 404.

The INVITE checks and the channel hashcash check move from the HTTP
handlers into the service, so both transports run the same code. An IRC
client cannot attach a stamp, so its PRIVMSG to a +H channel always
fails that check. The unfiltered channel list query, now unused, is
removed.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #139.
This commit is contained in:
2026-10-20 16:02:14 +02:00
committed by clawbot
parent 1308769a25
commit 3c725a9ca8
7 changed files with 494 additions and 450 deletions
+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
}