diff --git a/pkg/channels/buzz/buzz.go b/pkg/channels/buzz/buzz.go index 2510495c..a71feb26 100644 --- a/pkg/channels/buzz/buzz.go +++ b/pkg/channels/buzz/buzz.go @@ -10,6 +10,7 @@ import ( "fmt" "strings" "sync" + "time" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" @@ -23,6 +24,10 @@ import ( // kindStreamMessage is the Buzz chat message kind (NIP-29 style). const kindStreamMessage = 9 +// subscribeTimeout bounds how long Start waits for the relay to accept or +// reject a subscription before giving up. +const subscribeTimeout = 15 * time.Second + // BuzzChannel implements the Channel interface for a Buzz relay. type BuzzChannel struct { *channels.BaseChannel @@ -117,28 +122,17 @@ func (c *BuzzChannel) Start(ctx context.Context) error { } c.relay = relay - // NIP-42: the relay challenges, we sign the auth event with the bot identity. - // Buzz relays reject subscriptions from unauthenticated clients, so a failure - // here is fatal rather than advisory. - if err := relay.Auth(c.ctx, func(evt *nostr.Event) error { - return evt.Sign(c.secretKey) - }); err != nil { - _ = relay.Close() - c.cancel() - return fmt.Errorf("buzz NIP-42 auth failed: %w", err) - } - channelIDs := []string(c.config.Channels) filters := nostr.Filters{{ Kinds: []int{kindStreamMessage}, Tags: nostr.TagMap{"h": channelIDs}, }} - sub, err := relay.Subscribe(c.ctx, filters) + sub, err := c.subscribeAuthed(c.ctx, filters) if err != nil { _ = relay.Close() c.cancel() - return fmt.Errorf("buzz subscribe failed: %w", err) + return err } c.sub = sub @@ -157,6 +151,72 @@ func (c *BuzzChannel) Start(ctx context.Context) error { return nil } +// subscribeAuthed subscribes, performing the NIP-42 handshake if the relay +// demands it. +// +// The handshake MUST be driven by the relay's rejection rather than attempted +// eagerly after connect. go-nostr stores the relay's challenge on an unexported +// field populated by its reader goroutine when the AUTH envelope arrives; +// calling Auth() straight after RelayConnect signs an auth event with an empty +// challenge tag, which every relay rejects. Waiting for "auth-required" +// guarantees the challenge has been read — the CLOSED envelope is processed +// after the AUTH envelope on that same goroutine, and receiving it over a +// channel establishes the happens-before edge that makes the read safe. +func (c *BuzzChannel) subscribeAuthed( + ctx context.Context, + filters nostr.Filters, +) (*nostr.Subscription, error) { + sub, err := c.relay.Subscribe(ctx, filters) + if err != nil { + return nil, fmt.Errorf("buzz subscribe failed: %w", err) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-sub.EndOfStoredEvents: + // Relay accepted the subscription without requiring authentication. + return sub, nil + + case reason := <-sub.ClosedReason: + if !strings.HasPrefix(reason, "auth-required") { + return nil, fmt.Errorf("buzz relay closed subscription: %s", reason) + } + sub.Unsub() + + if err := c.relay.Auth(ctx, func(evt *nostr.Event) error { + return evt.Sign(c.secretKey) + }); err != nil { + return nil, fmt.Errorf("buzz NIP-42 auth failed: %w", err) + } + logger.InfoCF("buzz", "Authenticated to relay", map[string]any{ + "relay": c.config.RelayURL, + "pubkey": c.publicKey, + }) + + authed, err := c.relay.Subscribe(ctx, filters) + if err != nil { + return nil, fmt.Errorf("buzz subscribe after auth failed: %w", err) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-authed.EndOfStoredEvents: + return authed, nil + case reason := <-authed.ClosedReason: + // A second rejection means the identity is not permitted, not that + // the handshake was mistimed — retrying would loop forever. + return nil, fmt.Errorf("buzz relay rejected subscription after auth: %s", reason) + case <-time.After(subscribeTimeout): + return nil, fmt.Errorf("buzz timed out waiting for subscription after auth") + } + + case <-time.After(subscribeTimeout): + return nil, fmt.Errorf("buzz timed out waiting for relay to accept subscription") + } +} + // Stop closes the subscription and disconnects from the relay. func (c *BuzzChannel) Stop(ctx context.Context) error { logger.InfoC("buzz", "Stopping Buzz channel")