From d3926cfce1ddf589b42b37afa4ab3f3deaa13a63 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Sat, 14 Feb 2026 23:03:54 +0900 Subject: [PATCH 1/2] NIP-59: prevent gift wrap events from being returned to non-recipients --- handlers.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/handlers.go b/handlers.go index 2c1c1cb..0daf323 100644 --- a/handlers.go +++ b/handlers.go @@ -260,6 +260,27 @@ func (s *Server) doReq(ctx context.Context, ws *WebSocket, request []json.RawMes return "" } } + + // NIP-59: prevent gift wrap events from being returned to non-recipients + if slices.Contains(filter.Kinds, nostr.KindGiftWrap) { + receivers, _ := filter.Tags["p"] + switch { + case ws.authed == "": + ws.WriteJSON(nostr.ClosedEnvelope{ + SubscriptionID: id, + Reason: "restricted: this relay does not serve gift-wrapped events to unauthenticated users, does your client implement NIP-42?", + }) + return "" + case len(receivers) == 1 && receivers[0] == ws.authed: + // allowed: querying gift wraps addressed to self + default: + ws.WriteJSON(nostr.ClosedEnvelope{ + SubscriptionID: id, + Reason: "restricted: authenticated user does not have authorization for requested filters.", + }) + return "" + } + } } events, err := store.QueryEvents(ctx, filter) From f569e6c57dc9fddb6965648f87eba55ec077b1f6 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 27 Mar 2026 10:33:35 +0900 Subject: [PATCH 2/2] Add tests for NIP-59 gift wrap access control --- nip59_test.go | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 nip59_test.go diff --git a/nip59_test.go b/nip59_test.go new file mode 100644 index 0000000..5570e5a --- /dev/null +++ b/nip59_test.go @@ -0,0 +1,214 @@ +package relayer + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/fasthttp/websocket" + "github.com/fiatjaf/eventstore/slicestore" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip42" +) + +// testAutherRelay is a relay that implements Auther and CustomWebSocketHandler. +// The CustomWebSocketHandler exposes the challenge to the client for testing. +type testAutherRelay struct { + testRelay + serviceURL string +} + +func (r *testAutherRelay) ServiceURL() string { return r.serviceURL } + +func (r *testAutherRelay) HandleUnknownType(ws *WebSocket, typ string, request []json.RawMessage) { + if typ == "GETCHALLENGE" { + ws.WriteJSON([]string{"CHALLENGE", ws.challenge}) + } +} + +func startAutherRelay(t *testing.T) *Server { + t.Helper() + rl := &testAutherRelay{ + testRelay: testRelay{storage: &slicestore.SliceStore{}}, + serviceURL: "ws://localhost", + } + srv, _ := NewServer(rl) + started := make(chan bool) + go srv.Start("127.0.0.1", 0, started) + <-started + rl.serviceURL = "ws://" + srv.Addr + return srv +} + +func dialTestWS(t *testing.T, addr string) *websocket.Conn { + t.Helper() + conn, _, err := websocket.DefaultDialer.Dial("ws://"+addr, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +func readMsg(t *testing.T, conn *websocket.Conn) (string, []json.RawMessage) { + t.Helper() + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("readMessage: %v", err) + } + var raw []json.RawMessage + if err := json.Unmarshal(msg, &raw); err != nil { + t.Fatalf("unmarshal: %v (raw: %s)", err, msg) + } + var typ string + if len(raw) > 0 { + json.Unmarshal(raw[0], &typ) + } + return typ, raw +} + +// authenticate performs NIP-42 auth via the GETCHALLENGE custom message. +func authenticate(t *testing.T, conn *websocket.Conn, sk string, relayURL string) string { + t.Helper() + pub, _ := nostr.GetPublicKey(sk) + + // get challenge from server + conn.WriteJSON([]interface{}{"GETCHALLENGE", ""}) + typ, raw := readMsg(t, conn) + if typ != "CHALLENGE" { + t.Fatalf("expected CHALLENGE, got %s", typ) + } + var challenge string + json.Unmarshal(raw[1], &challenge) + + // create and sign auth event + authEvt := nip42.CreateUnsignedAuthEvent(challenge, pub, relayURL) + authEvt.Sign(sk) + + // send AUTH + conn.WriteJSON([]interface{}{"AUTH", authEvt}) + typ, raw = readMsg(t, conn) + if typ != "OK" { + t.Fatalf("expected OK, got %s", typ) + } + var ok bool + json.Unmarshal(raw[2], &ok) + if !ok { + var reason string + if len(raw) > 3 { + json.Unmarshal(raw[3], &reason) + } + t.Fatalf("AUTH failed: %s", reason) + } + + return pub +} + +func TestNIP59_GiftWrap_Unauthenticated(t *testing.T) { + srv := startAutherRelay(t) + defer srv.Shutdown(context.TODO()) + + conn := dialTestWS(t, srv.Addr) + + // REQ for kind 1059 without authentication + conn.WriteJSON([]interface{}{"REQ", "sub1", nostr.Filter{Kinds: []int{nostr.KindGiftWrap}}}) + + typ, raw := readMsg(t, conn) + if typ != "CLOSED" { + t.Fatalf("expected CLOSED, got %s", typ) + } + var reason string + json.Unmarshal(raw[2], &reason) + if reason != "restricted: this relay does not serve gift-wrapped events to unauthenticated users, does your client implement NIP-42?" { + t.Errorf("unexpected reason: %q", reason) + } +} + +func TestNIP59_GiftWrap_AuthenticatedSelf(t *testing.T) { + srv := startAutherRelay(t) + defer srv.Shutdown(context.TODO()) + + conn := dialTestWS(t, srv.Addr) + sk := nostr.GeneratePrivateKey() + pub := authenticate(t, conn, sk, "ws://"+srv.Addr) + + // REQ for gift wraps addressed to self + conn.WriteJSON([]interface{}{"REQ", "sub1", nostr.Filter{ + Kinds: []int{nostr.KindGiftWrap}, + Tags: nostr.TagMap{"p": []string{pub}}, + }}) + + typ, _ := readMsg(t, conn) + if typ != "EOSE" { + t.Fatalf("expected EOSE (allowed), got %s", typ) + } +} + +func TestNIP59_GiftWrap_AuthenticatedOther(t *testing.T) { + srv := startAutherRelay(t) + defer srv.Shutdown(context.TODO()) + + conn := dialTestWS(t, srv.Addr) + sk := nostr.GeneratePrivateKey() + authenticate(t, conn, sk, "ws://"+srv.Addr) + + otherPub, _ := nostr.GetPublicKey(nostr.GeneratePrivateKey()) + + // REQ for gift wraps addressed to someone else + conn.WriteJSON([]interface{}{"REQ", "sub1", nostr.Filter{ + Kinds: []int{nostr.KindGiftWrap}, + Tags: nostr.TagMap{"p": []string{otherPub}}, + }}) + + typ, raw := readMsg(t, conn) + if typ != "CLOSED" { + t.Fatalf("expected CLOSED, got %s", typ) + } + var reason string + json.Unmarshal(raw[2], &reason) + if reason != "restricted: authenticated user does not have authorization for requested filters." { + t.Errorf("unexpected reason: %q", reason) + } +} + +func TestNIP59_GiftWrap_AuthenticatedNoFilter(t *testing.T) { + srv := startAutherRelay(t) + defer srv.Shutdown(context.TODO()) + + conn := dialTestWS(t, srv.Addr) + sk := nostr.GeneratePrivateKey() + authenticate(t, conn, sk, "ws://"+srv.Addr) + + // REQ for gift wraps with no p filter + conn.WriteJSON([]interface{}{"REQ", "sub1", nostr.Filter{ + Kinds: []int{nostr.KindGiftWrap}, + }}) + + typ, raw := readMsg(t, conn) + if typ != "CLOSED" { + t.Fatalf("expected CLOSED, got %s", typ) + } + var reason string + json.Unmarshal(raw[2], &reason) + if reason != "restricted: authenticated user does not have authorization for requested filters." { + t.Errorf("unexpected reason: %q", reason) + } +} + +func TestNIP59_GiftWrap_NonAutherRelay(t *testing.T) { + // Without Auther, NIP-59 guard should not activate + srv := startTestRelay(t, &testRelay{storage: &slicestore.SliceStore{}}) + defer srv.Shutdown(context.TODO()) + + conn := dialTestWS(t, srv.Addr) + + // REQ for kind 1059 on a non-Auther relay should just return EOSE + conn.WriteJSON([]interface{}{"REQ", "sub1", nostr.Filter{Kinds: []int{nostr.KindGiftWrap}}}) + + typ, _ := readMsg(t, conn) + if typ != "EOSE" { + t.Fatalf("expected EOSE (no auth required), got %s", typ) + } +}