Enforce channel keys, invites, +s and +H on the IRC listener (closes #122)
check / check (push) Waiting to run
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:
+205
-23
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user