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
+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
}