From fd53d7309fd195d8eab34946c0d97712e60f6b23 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 6 Sep 2025 10:11:31 -0300 Subject: [PATCH] khatru: support multi-user auth. --- khatru/handlers.go | 21 +++++++++++++++++---- khatru/relay.go | 13 ++++++++----- khatru/utils.go | 31 +++++++++++++++++++++++++------ khatru/websocket.go | 8 +++----- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/khatru/handlers.go b/khatru/handlers.go index a9a7e10..d48f794 100644 --- a/khatru/handlers.go +++ b/khatru/handlers.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "net/http" + "slices" "strconv" "strings" "sync" @@ -76,6 +77,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) { conn: conn, Request: r, Challenge: rl.ChallengePrefix + hex.EncodeToString(challenge), + AuthedPublicKeys: make([]nostr.PubKey, 0), negentropySessions: xsync.NewMapOf[string, *NegentropySession](), } ws.Context, ws.cancel = context.WithCancel(context.Background()) @@ -317,11 +319,22 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) { case *nostr.AuthEnvelope: wsBaseUrl := strings.Replace(rl.getBaseURL(r), "http", "ws", 1) if pubkey, ok := nip42.ValidateAuthEvent(env.Event, ws.Challenge, wsBaseUrl); ok { - ws.AuthedPublicKey = pubkey + + total := len(ws.AuthedPublicKeys) - 1 ws.authLock.Lock() - if ws.Authed != nil { - close(ws.Authed) - ws.Authed = nil + if idx := slices.Index(ws.AuthedPublicKeys, pubkey); idx == -1 { + // this public key is not authenticated + if total < rl.MaxAuthenticatedClients { + // add it to the end (the last pubkey is the one we'll use in a single-user context) + ws.AuthedPublicKeys = append(ws.AuthedPublicKeys, pubkey) + } else { + // remove the first (oldest) and add the new pubkey to the end + ws.AuthedPublicKeys[0] = ws.AuthedPublicKeys[total-1] + ws.AuthedPublicKeys[total-1] = pubkey + } + } else { + // this is already authed, so move it to the end + ws.AuthedPublicKeys[idx], ws.AuthedPublicKeys[total-1] = ws.AuthedPublicKeys[total-1], ws.AuthedPublicKeys[idx] } ws.authLock.Unlock() ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true}) diff --git a/khatru/relay.go b/khatru/relay.go index ab6af4e..7a9ce6f 100644 --- a/khatru/relay.go +++ b/khatru/relay.go @@ -26,7 +26,7 @@ func NewRelay() *Relay { Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags), Info: &nip11.RelayInformationDocument{ - Software: "https://fiatjaf.com/nostr/khatru", + Software: "https://pkg.go.dev/fiatjaf.com/nostr/khatru", Version: "n/a", SupportedNIPs: []any{1, 11, 40, 42, 70, 86}, }, @@ -46,6 +46,8 @@ func NewRelay() *Relay { PongWait: 60 * time.Second, PingPeriod: 30 * time.Second, MaxMessageSize: 512000, + + MaxAuthenticatedClients: 32, } rl.expirationManager = newExpirationManager(rl) @@ -112,10 +114,11 @@ type Relay struct { httpServer *http.Server // websocket options - WriteWait time.Duration // Time allowed to write a message to the peer. - PongWait time.Duration // Time allowed to read the next pong message from the peer. - PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait. - MaxMessageSize int64 // Maximum message size allowed from peer. + WriteWait time.Duration // Time allowed to write a message to the peer. + PongWait time.Duration // Time allowed to read the next pong message from the peer. + PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait. + MaxMessageSize int64 // Maximum message size allowed from peer. + MaxAuthenticatedClients int // NIP-40 expiration manager expirationManager *expirationManager diff --git a/khatru/utils.go b/khatru/utils.go index 55536c3..578c47d 100644 --- a/khatru/utils.go +++ b/khatru/utils.go @@ -15,11 +15,6 @@ const ( func RequestAuth(ctx context.Context) { ws := GetConnection(ctx) - ws.authLock.Lock() - if ws.Authed == nil { - ws.Authed = make(chan struct{}) - } - ws.authLock.Unlock() ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge}) } @@ -31,9 +26,16 @@ func GetConnection(ctx context.Context) *WebSocket { return nil } +// GetAuthed returns the last pubkey to have authenticated. Returns false if no one has. +// +// In a NIP-86 context it returns the single pubkey that have authenticated for that specific method call. func GetAuthed(ctx context.Context) (nostr.PubKey, bool) { if conn := GetConnection(ctx); conn != nil { - return conn.AuthedPublicKey, conn.AuthedPublicKey != nostr.ZeroPK + total := len(conn.AuthedPublicKeys) + if total == 0 { + return nostr.ZeroPK, false + } + return conn.AuthedPublicKeys[total-1], true } if nip86Auth := ctx.Value(nip86HeaderAuthKey); nip86Auth != nil { return nip86Auth.(nostr.PubKey), true @@ -41,6 +43,23 @@ func GetAuthed(ctx context.Context) (nostr.PubKey, bool) { return nostr.ZeroPK, false } +// IsAuthed checks if the given public key is among the multiple that may have potentially authenticated. +func IsAuthed(ctx context.Context, pubkey nostr.PubKey) bool { + if conn := GetConnection(ctx); conn != nil { + for _, pk := range conn.AuthedPublicKeys { + if pk == pubkey { + return true + } + } + } + + if nip86Auth := ctx.Value(nip86HeaderAuthKey); nip86Auth != nil { + return nip86Auth.(nostr.PubKey) == pubkey + } + + return false +} + // IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion // or expiration request. func IsInternalCall(ctx context.Context) bool { diff --git a/khatru/websocket.go b/khatru/websocket.go index af40533..c5b984e 100644 --- a/khatru/websocket.go +++ b/khatru/websocket.go @@ -22,14 +22,12 @@ type WebSocket struct { cancel context.CancelFunc // nip42 - Challenge string - AuthedPublicKey nostr.PubKey - Authed chan struct{} + Challenge string + AuthedPublicKeys []nostr.PubKey + authLock sync.Mutex // nip77 negentropySessions *xsync.MapOf[string, *NegentropySession] - - authLock sync.Mutex } func (ws *WebSocket) WriteJSON(any any) error {