Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add NIP-42 support for client to relay authentication within the library #67

Merged
merged 1 commit into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Examples/request-events-from-multiple-relays.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
53 changes: 53 additions & 0 deletions src/Examples/request-events-with-auth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\RelayResponse\RelayResponseEvent;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;

require __DIR__ . '/../../vendor/autoload.php';

try {
$subscription = new Subscription();
$subscriptionId = $subscription->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;
}
6 changes: 4 additions & 2 deletions src/Examples/request-events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions src/Message/AuthMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace swentel\nostr\Message;

use swentel\nostr\EventInterface;
use swentel\nostr\MessageInterface;

class AuthMessage implements MessageInterface
{
/**
* @var string $type
*/
private string $type;

/**
* The event.
*
* @var EventInterface
*/
protected EventInterface $event;

public function __construct(EventInterface $event)
{
$this->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 . ']';
}
}
1 change: 1 addition & 0 deletions src/Message/MessageTypeEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ enum MessageTypeEnum: string
case EVENT = 'EVENT';
case REQUEST = 'REQ';
case CLOSE = 'CLOSE';
case AUTH = 'AUTH';
}
33 changes: 33 additions & 0 deletions src/Nip42/AuthEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace swentel\nostr\Nip42;

use swentel\nostr\Event\Event;

/**
* NIP-42: https://github.com/nostr-protocol/nips/blob/master/42.md
* AuthEvent class for canonical authentication event.
*/
class AuthEvent extends Event
{
/**
* Event kind for canonical authentication event sent to the relay.
*
* @var int
*/
protected int $kind = 22242;

/**
* Base constructor for AuthEvent.
*/
public function __construct($relayUri, $challenge)
{
parent::__construct();
$this->setTags([
['relay', $relayUri],
['challenge', $challenge],
]);
}
}
89 changes: 81 additions & 8 deletions src/Request/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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.
Expand Down Expand Up @@ -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
{
Expand All @@ -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) {
Expand All @@ -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;
}
}
6 changes: 5 additions & 1 deletion tests/RelayResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
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;

class RelayResponseTest extends TestCase
{
public function testSendRequestToRelayAndResultAuth()
{
$relayUrl = 'wss://nostr.sebastix.social';
$relayUrl = 'wss://jingle.nostrver.se';

$relay = new Relay($relayUrl);

Expand All @@ -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]);
}
}