diff --git a/README.md b/README.md index fe20e32..190e186 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ private key on command line. - [x] `NOTICE` - used to send human-readable messages (like errors) to clients - [x] Improve handling relay responses - [ ] Support NIP-19 bech32-encoded identifiers -- [ ] Support NIP-42 authentication of clients to relays => AUTH relay response +- [x] Support NIP-42 authentication of clients to relays - [ ] Support NIP-45 event counts - [ ] Support NIP-50 search capability - [ ] Support multi-threading (async concurrency) for handling requests simultaneously diff --git a/src/Examples/request-events-from-multiple-relays.php b/src/Examples/request-events-from-multiple-relays.php index af499ba..4e653ef 100644 --- a/src/Examples/request-events-from-multiple-relays.php +++ b/src/Examples/request-events-from-multiple-relays.php @@ -42,7 +42,7 @@ $response = $request->send(); foreach ($response as $relayUrl => $relayResponses) { - print 'Received ' . count($response[$relayUrl]) . ' message(s) found from relay ' . $relayUrl . PHP_EOL; + print 'Received ' . count($response[$relayUrl]) . ' message(s) received from relay ' . $relayUrl . PHP_EOL; foreach ($relayResponses as $message) { print $message->event->content . PHP_EOL; } diff --git a/src/Examples/request-events-with-auth.php b/src/Examples/request-events-with-auth.php new file mode 100644 index 0000000..8da60ac --- /dev/null +++ b/src/Examples/request-events-with-auth.php @@ -0,0 +1,53 @@ +setId(); + $filter1 = new Filter(); + $filter1->setAuthors([ + 'npub1qe3e5wrvnsgpggtkytxteaqfprz0rgxr8c3l34kk3a9t7e2l3acslezefe', + ]); + $filter1->setKinds([1]); + $filter1->setLimit(3); + $filters = [$filter1]; + $requestMessage = new RequestMessage($subscriptionId, $filters); + $relay = new Relay('wss://jingle.nostrver.se'); + //$relay = new Relay('wss://hotrightnow.nostr1.com'); + $request = new Request($relay, $requestMessage); + $response = $request->send(); + + foreach ($response as $relay => $messages) { + print 'Received ' . count($response[$relay]) . ' message(s) received from relay ' . $relay . PHP_EOL; + foreach ($messages as $message) { + print $message->type . ': ' . $message->message . PHP_EOL; + if ($message instanceof RelayResponseEvent) { + $rawEvent = $message->event; + $event = new Event(); + $event->setId($rawEvent->id); + $event->setPublicKey($rawEvent->pubkey); + $event->setCreatedAt($rawEvent->created_at); + $event->setKind($rawEvent->kind); + $event->setTags($rawEvent->tags); + $event->setContent($rawEvent->content); + $event->setSignature($rawEvent->sig); + if ($event->verify() === true) { + var_dump($event->getContent()); + } + } + } + } +} catch (Exception $e) { + print $e->getMessage() . PHP_EOL; +} diff --git a/src/Examples/request-events.php b/src/Examples/request-events.php index 838b6e8..b560a5b 100644 --- a/src/Examples/request-events.php +++ b/src/Examples/request-events.php @@ -31,10 +31,12 @@ * Each message will also contain the event. */ foreach ($response as $relayUrl => $relayResponses) { - print 'Received ' . count($response[$relayUrl]) . ' message(s) found from relay ' . $relayUrl . PHP_EOL; + print 'Received ' . count($response[$relayUrl]) . ' message(s) received from relay ' . $relayUrl . PHP_EOL; /** @var \swentel\nostr\RelayResponse\RelayResponseEvent $message */ foreach ($relayResponses as $message) { - print $message->event->content . PHP_EOL; + if (isset($message->event->content)) { + print $message->event->content . PHP_EOL; + } } } } catch (Exception $e) { diff --git a/src/Message/AuthMessage.php b/src/Message/AuthMessage.php new file mode 100644 index 0000000..a056a10 --- /dev/null +++ b/src/Message/AuthMessage.php @@ -0,0 +1,49 @@ +event = $event; + $this->setType(MessageTypeEnum::AUTH); + } + + /** + * Set message type. + * + * @param MessageTypeEnum $type + * @return void + */ + public function setType(MessageTypeEnum $type): void + { + $this->type = $type->value; + } + + /** + * {@inheritdoc} + */ + public function generate(): string + { + $event = json_encode($this->event->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return '["' . $this->type . '", ' . $event . ']'; + } +} diff --git a/src/Message/MessageTypeEnum.php b/src/Message/MessageTypeEnum.php index 97b59cb..7e74a96 100644 --- a/src/Message/MessageTypeEnum.php +++ b/src/Message/MessageTypeEnum.php @@ -12,4 +12,5 @@ enum MessageTypeEnum: string case EVENT = 'EVENT'; case REQUEST = 'REQ'; case CLOSE = 'CLOSE'; + case AUTH = 'AUTH'; } diff --git a/src/Nip42/AuthEvent.php b/src/Nip42/AuthEvent.php new file mode 100644 index 0000000..eb92e7e --- /dev/null +++ b/src/Nip42/AuthEvent.php @@ -0,0 +1,33 @@ +setTags([ + ['relay', $relayUri], + ['challenge', $challenge], + ]); + } +} diff --git a/src/Request/Request.php b/src/Request/Request.php index 10323f3..737cbee 100644 --- a/src/Request/Request.php +++ b/src/Request/Request.php @@ -4,11 +4,18 @@ namespace swentel\nostr\Request; +use swentel\nostr\Event\Event; +use swentel\nostr\Message\AuthMessage; +use swentel\nostr\Nip42\AuthEvent; use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\RelaySet; use swentel\nostr\RelayResponse\RelayResponse; use swentel\nostr\RequestInterface; +use swentel\nostr\Sign\Sign; use WebSocket; +use WebSocket\Client; +use WebSocket\Connection; +use WebSocket\Message\Text; class Request implements RequestInterface { @@ -26,6 +33,13 @@ class Request implements RequestInterface */ private string $payload; + /** + * Array with all responses received from the relay. + * + * @var array + */ + protected array $responses; + /** * Constructor for the Request class. * Initializes the url and payload properties based on the provided websocket and message. @@ -71,7 +85,8 @@ public function send(): array * Method to send a request using WebSocket client, receive responses, and handle errors. * * @param Relay $relay - * @return array + * @return array|RelayResponse + * @throws \Throwable */ private function getResponseFromRelay(Relay $relay): array | RelayResponse { @@ -86,9 +101,9 @@ private function getResponseFromRelay(Relay $relay): array | RelayResponse * connection is still alive, but it does not confirm the closure of the subscription) */ - $client = new WebSocket\Client($relay->getUrl()); + $client = new Client($relay->getUrl()); + $client->setTimeout(60); $client->text($this->payload); - $result = []; while ($response = $client->receive()) { if ($response === null) { @@ -100,17 +115,75 @@ private function getResponseFromRelay(Relay $relay): array | RelayResponse return RelayResponse::create($response); } elseif ($response instanceof WebSocket\Message\Ping) { $client->disconnect(); - return $result; - } elseif ($response instanceof WebSocket\Message\Text) { + return $this->responses; + } elseif ($response instanceof Text) { $relayResponse = RelayResponse::create(json_decode($response->getContent())); + $this->responses[] = $relayResponse; if ($relayResponse->type === 'EOSE') { + $client->disconnect(); break; } - - $result[] = $relayResponse; + if ($relayResponse->type === 'OK' && $relayResponse->status === false) { + $client->disconnect(); + throw new \Exception($relayResponse->message); + } + // NIP-42 + if ($relayResponse->type === 'AUTH') { + // Save challenge string in session. + $_SESSION['challenge'] = $relayResponse->message; + } + if ($relayResponse->type === 'CLOSED') { + // NIP-42 + // We do need to broadcast a signed event verification here to the relay. + if (str_starts_with($relayResponse->message, 'auth-required:')) { + if (!isset($_SESSION['challenge'])) { + $client->disconnect(); + throw new \Exception('No challenge set in $_SESSION'); + } + $aa = new AuthEvent($relay->getUrl(), $_SESSION['challenge']); + $authEvent = new Event(); + $authEvent->setKind(22242); + $authEvent->setTags([ + ['relay', $relay->getUrl()], + ['challenge', $_SESSION['challenge']], + ]); + $sec = '0000000000000000000000000000000000000000000000000000000000000001'; + // todo: use client defined secret key here instead of this default one + $signer = new Sign(); + $signer->signEvent($aa, $sec); + $authMessage = new AuthMessage($aa); + $initialMessage = $this->payload; + $this->payload = $authMessage->generate(); + $client->text($this->payload); + // Set listener. + $client->onText(function (Client $client, Connection $connection, Text $message) { + $this->responses[] = RelayResponse::create(json_decode($message->getContent())); + $client->stop(); + })->start(); + // Broadcast the initial message to the relay now the AUTH is done. + $this->payload = $initialMessage; + $client->text($this->payload); + $client->onText(function (Client $client, Connection $connection, Text $message) { + /** @var RelayResponse $response */ + $response = RelayResponse::create(json_decode($message->getContent())); + $this->responses[] = $response; + if ($response->type === 'EOSE') { + $client->disconnect(); + } + })->start(); + break; + } + if (str_starts_with($relayResponse->message, 'restricted:')) { + // For when a client has already performed AUTH but the key used to perform + // it is still not allowed by the relay or is exceeding its authorization. + $client->disconnect(); + throw new \Exception($relayResponse->message); + } + } } } $client->disconnect(); - return $result; + $client->close(); + return $this->responses; } } diff --git a/tests/RelayResponseTest.php b/tests/RelayResponseTest.php index 4a32449..5b24fe5 100644 --- a/tests/RelayResponseTest.php +++ b/tests/RelayResponseTest.php @@ -7,6 +7,8 @@ use swentel\nostr\Message\RequestMessage; use swentel\nostr\Relay\Relay; use swentel\nostr\RelayResponse\RelayResponseAuth; +use swentel\nostr\RelayResponse\RelayResponseClosed; +use swentel\nostr\RelayResponse\RelayResponseOk; use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; @@ -14,7 +16,7 @@ class RelayResponseTest extends TestCase { public function testSendRequestToRelayAndResultAuth() { - $relayUrl = 'wss://nostr.sebastix.social'; + $relayUrl = 'wss://jingle.nostrver.se'; $relay = new Relay($relayUrl); @@ -33,5 +35,7 @@ public function testSendRequestToRelayAndResultAuth() $result = $request->send(); $this->assertInstanceOf(RelayResponseAuth::class, $result[$relayUrl][0]); + $this->assertInstanceOf(RelayResponseClosed::class, $result[$relayUrl][1]); + $this->assertInstanceOf(RelayResponseOk::class, $result[$relayUrl][2]); } }