From e183b62aba949ba10b6878cc8f684addc8ff5b50 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 15:21:45 -0500 Subject: [PATCH] feat: implement Nostr NIP-01 WebSocket relay publishing (#129) - BIP-340 Schnorr signatures over secp256k1 (pure PHP, requires ext-gmp) - Kind-1 text note events with SHA-256 event ID and tagged hashes - Raw WebSocket client via stream_socket_client (zero external deps) - Multi-relay failover: tries each relay until one accepts - Public key derivation from private key for account display - Validates 64-char hex private key format and wss:// relay URLs Authored-by: Moko Consulting --- CHANGELOG.md | 6 + .../en-GB/plg_mokosuitecross_nostr.ini | 2 +- .../src/Extension/NostrService.php | 389 +++++++++++++++++- 3 files changed, 379 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1637a6..293b4109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog ## [Unreleased] +### Added +- **Nostr plugin**: Full NIP-01 WebSocket relay publishing with BIP-340 Schnorr signatures (pure PHP, requires ext-gmp) +- **Nostr**: Publishes kind-1 text note events to multiple relays with automatic failover +- **Nostr**: Raw WebSocket client using stream_socket_client (no external dependencies) +- **Nostr**: Public key derivation and event signing via secp256k1 elliptic curve math + ### Fixed - Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` diff --git a/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini b/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini index 3c87b214..e5de006f 100644 --- a/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini +++ b/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini @@ -1,2 +1,2 @@ PLG_MOKOSUITECROSS_NOSTR="MokoSuiteCross - Nostr" -PLG_MOKOSUITECROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr." +PLG_MOKOSUITECROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr relays via NIP-01 WebSocket protocol. Requires PHP ext-gmp for secp256k1 Schnorr signing." diff --git a/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php b/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php index 56a5eed7..2f27c9e7 100644 --- a/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php +++ b/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php @@ -20,12 +20,18 @@ use Joomla\Event\SubscriberInterface; /** * Nostr service plugin for MokoSuiteCross. * - * Nostr uses NIP-01 WebSocket relays for event publishing. - * This is a stub — full WebSocket implementation is deferred. - * Events are signed with the private key and sent to configured relays. + * Publishes kind-1 text note events to NIP-01 WebSocket relays. + * Uses BIP-340 Schnorr signatures over secp256k1 (requires ext-gmp). + * + * Credentials: private_key (64-char hex nsec), relays (comma-separated wss:// URLs) */ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface { + private const SECP256K1_P = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F'; + private const SECP256K1_N = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'; + private const SECP256K1_GX = '79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'; + private const SECP256K1_GY = '483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8'; + public static function getSubscribedEvents(): array { return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; @@ -43,6 +49,10 @@ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr public function publish(string $message, array $media, array $credentials, array $params): array { + if (!extension_loaded('gmp')) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'PHP ext-gmp is required for Nostr signing.']]; + } + $privateKey = $credentials['private_key'] ?? ''; $relays = $credentials['relays'] ?? ''; @@ -50,48 +60,393 @@ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing private key or relay URLs.']]; } - // Nostr requires WebSocket connections to relays (wss://). - // Full NIP-01 event signing and relay publishing is not yet implemented. + $privateKey = strtolower(trim($privateKey)); + + if (!preg_match('/^[0-9a-f]{64}$/', $privateKey)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Private key must be 64 hex characters.']]; + } + + $pubkey = $this->getPublicKey($privateKey); + + $event = $this->createEvent($pubkey, $message); + $event['sig'] = $this->schnorrSign($event['id'], $privateKey); + + $relayList = array_filter(array_map('trim', explode(',', $relays))); + $lastError = ''; + $published = false; + + foreach ($relayList as $relayUrl) { + $result = $this->publishToRelay($relayUrl, $event); + + if ($result['success']) { + $published = true; + break; + } + + $lastError = $result['error']; + } + + if (!$published) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'All relays failed. Last: ' . $lastError]]; + } + return [ - 'success' => false, - 'platform_post_id' => '', - 'response' => ['error' => 'Nostr WebSocket relay publishing is not yet implemented. This service will be available in a future release.'], + 'success' => true, + 'platform_post_id' => $event['id'], + 'response' => ['event_id' => $event['id'], 'relay' => $relayUrl ?? ''], ]; } public function validateCredentials(array $credentials): array { - $privateKey = $credentials['private_key'] ?? ''; + if (!extension_loaded('gmp')) { + return ['valid' => false, 'message' => 'PHP ext-gmp is required for Nostr.', 'account_name' => '']; + } + + $privateKey = strtolower(trim($credentials['private_key'] ?? '')); $relays = $credentials['relays'] ?? ''; if (empty($privateKey)) { return ['valid' => false, 'message' => 'Private key is required.', 'account_name' => '']; } + if (!preg_match('/^[0-9a-f]{64}$/', $privateKey)) { + return ['valid' => false, 'message' => 'Private key must be 64 hex characters.', 'account_name' => '']; + } + if (empty($relays)) { return ['valid' => false, 'message' => 'At least one relay URL is required.', 'account_name' => '']; } - // Validate that relay URLs look like WebSocket URLs $relayList = array_filter(array_map('trim', explode(',', $relays))); - $valid = true; foreach ($relayList as $relay) { if (!str_starts_with($relay, 'wss://') && !str_starts_with($relay, 'ws://')) { - $valid = false; - break; + return ['valid' => false, 'message' => 'Relay URLs must start with wss:// or ws://.', 'account_name' => '']; } } - if (!$valid) { - return ['valid' => false, 'message' => 'Relay URLs must start with wss:// or ws://.', 'account_name' => '']; - } + $pubkey = $this->getPublicKey($privateKey); + $npub = substr($pubkey, 0, 16) . '...'; - return ['valid' => true, 'message' => 'Credentials configured (' . count($relayList) . ' relay(s))', 'account_name' => 'Nostr']; + return ['valid' => true, 'message' => count($relayList) . ' relay(s) configured', 'account_name' => 'npub:' . $npub]; } public function getSupportedMediaTypes(): array { return []; } + + // -- NIP-01 event creation -- + + private function createEvent(string $pubkey, string $content, int $kind = 1, array $tags = []): array + { + $createdAt = time(); + $serialized = json_encode([0, $pubkey, $createdAt, $kind, $tags, $content], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $id = hash('sha256', $serialized); + + return [ + 'id' => $id, + 'pubkey' => $pubkey, + 'created_at' => $createdAt, + 'kind' => $kind, + 'tags' => $tags, + 'content' => $content, + 'sig' => '', + ]; + } + + // -- WebSocket relay publishing -- + + private function publishToRelay(string $relayUrl, array $event): array + { + $parsed = parse_url($relayUrl); + + if (!$parsed || !isset($parsed['host'])) { + return ['success' => false, 'error' => 'Invalid relay URL']; + } + + $scheme = $parsed['scheme'] ?? 'wss'; + $host = $parsed['host']; + $port = $parsed['port'] ?? ($scheme === 'wss' ? 443 : 80); + $path = $parsed['path'] ?? '/'; + $useTls = ($scheme === 'wss'); + + $address = ($useTls ? 'tls://' : 'tcp://') . $host . ':' . $port; + $context = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + + $socket = @stream_socket_client($address, $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context); + + if (!$socket) { + return ['success' => false, 'error' => "Connection failed: {$errstr} ({$errno})"]; + } + + stream_set_timeout($socket, 10); + + // WebSocket upgrade handshake + $wsKey = base64_encode(random_bytes(16)); + $handshake = "GET {$path} HTTP/1.1\r\n" + . "Host: {$host}\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Key: {$wsKey}\r\n" + . "Sec-WebSocket-Version: 13\r\n" + . "\r\n"; + + fwrite($socket, $handshake); + + $response = ''; + + while (($line = fgets($socket)) !== false) { + $response .= $line; + + if (trim($line) === '') { + break; + } + } + + if (strpos($response, '101') === false) { + fclose($socket); + + return ['success' => false, 'error' => 'WebSocket upgrade failed']; + } + + // Send EVENT message + $payload = json_encode(['EVENT', $event], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $this->wsWrite($socket, $payload); + + // Read OK response (with timeout) + $reply = $this->wsRead($socket); + fclose($socket); + + if ($reply === null) { + return ['success' => false, 'error' => 'No response from relay']; + } + + $decoded = json_decode($reply, true); + + if (!is_array($decoded) || ($decoded[0] ?? '') !== 'OK') { + $msg = is_array($decoded) ? ($decoded[3] ?? $decoded[2] ?? 'Unknown error') : 'Invalid response'; + + return ['success' => false, 'error' => (string) $msg]; + } + + // ["OK", event_id, true/false, message] + $accepted = $decoded[2] ?? false; + + if (!$accepted) { + return ['success' => false, 'error' => $decoded[3] ?? 'Relay rejected event']; + } + + return ['success' => true, 'error' => '']; + } + + private function wsWrite($socket, string $data): void + { + $len = strlen($data); + $frame = chr(0x81); // text frame, FIN bit set + $mask = random_bytes(4); + + if ($len < 126) { + $frame .= chr($len | 0x80); // mask bit set + } elseif ($len < 65536) { + $frame .= chr(126 | 0x80) . pack('n', $len); + } else { + $frame .= chr(127 | 0x80) . pack('J', $len); + } + + $frame .= $mask; + + for ($i = 0; $i < $len; $i++) { + $frame .= $data[$i] ^ $mask[$i % 4]; + } + + fwrite($socket, $frame); + } + + private function wsRead($socket): ?string + { + $header = fread($socket, 2); + + if ($header === false || strlen($header) < 2) { + return null; + } + + $len = ord($header[1]) & 0x7F; + + if ($len === 126) { + $ext = fread($socket, 2); + $len = unpack('n', $ext)[1]; + } elseif ($len === 127) { + $ext = fread($socket, 8); + $len = unpack('J', $ext)[1]; + } + + $masked = (ord($header[1]) & 0x80) !== 0; + $mask = $masked ? fread($socket, 4) : ''; + $data = ''; + + while (strlen($data) < $len) { + $chunk = fread($socket, $len - strlen($data)); + + if ($chunk === false) { + break; + } + + $data .= $chunk; + } + + if ($masked) { + for ($i = 0; $i < strlen($data); $i++) { + $data[$i] = $data[$i] ^ $mask[$i % 4]; + } + } + + return $data; + } + + // -- BIP-340 Schnorr signature over secp256k1 -- + + private function getPublicKey(string $privateKeyHex): string + { + $d = gmp_init($privateKeyHex, 16); + $G = [gmp_init(self::SECP256K1_GX, 16), gmp_init(self::SECP256K1_GY, 16)]; + + $point = $this->ecMultiply($G, $d); + + return str_pad(gmp_strval($point[0], 16), 64, '0', STR_PAD_LEFT); + } + + private function schnorrSign(string $messageHex, string $privateKeyHex): string + { + $p = gmp_init(self::SECP256K1_P, 16); + $n = gmp_init(self::SECP256K1_N, 16); + $G = [gmp_init(self::SECP256K1_GX, 16), gmp_init(self::SECP256K1_GY, 16)]; + + $d = gmp_init($privateKeyHex, 16); + $P = $this->ecMultiply($G, $d); + $px = str_pad(gmp_strval($P[0], 16), 64, '0', STR_PAD_LEFT); + + // BIP-340: if P.y is odd, negate d + if (gmp_testbit($P[1], 0)) { + $d = gmp_sub($n, $d); + } + + $dBytes = hex2bin(str_pad(gmp_strval($d, 16), 64, '0', STR_PAD_LEFT)); + $auxRand = random_bytes(32); + $t = $dBytes ^ $this->taggedHash('BIP0340/aux', $auxRand); + $pxBytes = hex2bin($px); + $msgBytes = hex2bin($messageHex); + + $rand = $this->taggedHash('BIP0340/nonce', $t . $pxBytes . $msgBytes); + $k0 = gmp_mod(gmp_init(bin2hex($rand), 16), $n); + + if (gmp_cmp($k0, 0) === 0) { + return str_repeat('00', 64); + } + + $R = $this->ecMultiply($G, $k0); + $k = gmp_testbit($R[1], 0) ? gmp_sub($n, $k0) : $k0; + + $rx = str_pad(gmp_strval($R[0], 16), 64, '0', STR_PAD_LEFT); + $rxBytes = hex2bin($rx); + + $eHash = $this->taggedHash('BIP0340/challenge', $rxBytes . $pxBytes . $msgBytes); + $e = gmp_mod(gmp_init(bin2hex($eHash), 16), $n); + + $s = gmp_mod(gmp_add($k, gmp_mul($e, $d)), $n); + + return $rx . str_pad(gmp_strval($s, 16), 64, '0', STR_PAD_LEFT); + } + + private function taggedHash(string $tag, string $data): string + { + $tagHash = hash('sha256', $tag, true); + + return hash('sha256', $tagHash . $tagHash . $data, true); + } + + // -- secp256k1 elliptic curve arithmetic -- + + private function ecMultiply(array $point, \GMP $scalar): array + { + $result = null; + $addend = $point; + $n = gmp_init(self::SECP256K1_N, 16); + + $scalar = gmp_mod($scalar, $n); + + while (gmp_cmp($scalar, 0) > 0) { + if (gmp_testbit($scalar, 0)) { + $result = $result === null ? $addend : $this->ecAdd($result, $addend); + } + + $addend = $this->ecDouble($addend); + $scalar = gmp_div_q($scalar, 2); + } + + return $result; + } + + private function ecAdd(array $p1, array $p2): array + { + $prime = gmp_init(self::SECP256K1_P, 16); + + if (gmp_cmp($p1[0], $p2[0]) === 0 && gmp_cmp($p1[1], $p2[1]) === 0) { + return $this->ecDouble($p1); + } + + $dx = gmp_mod(gmp_sub($p2[0], $p1[0]), $prime); + + if (gmp_cmp($dx, 0) < 0) { + $dx = gmp_add($dx, $prime); + } + + $dy = gmp_mod(gmp_sub($p2[1], $p1[1]), $prime); + + if (gmp_cmp($dy, 0) < 0) { + $dy = gmp_add($dy, $prime); + } + + $slope = gmp_mod(gmp_mul($dy, gmp_invert($dx, $prime)), $prime); + $x3 = gmp_mod(gmp_sub(gmp_sub(gmp_mul($slope, $slope), $p1[0]), $p2[0]), $prime); + $y3 = gmp_mod(gmp_sub(gmp_mul($slope, gmp_sub($p1[0], $x3)), $p1[1]), $prime); + + if (gmp_cmp($x3, 0) < 0) { + $x3 = gmp_add($x3, $prime); + } + + if (gmp_cmp($y3, 0) < 0) { + $y3 = gmp_add($y3, $prime); + } + + return [$x3, $y3]; + } + + private function ecDouble(array $point): array + { + $prime = gmp_init(self::SECP256K1_P, 16); + + // secp256k1 has a=0, so slope = 3*x^2 / (2*y) + $num = gmp_mod(gmp_mul(3, gmp_mul($point[0], $point[0])), $prime); + $denom = gmp_mod(gmp_mul(2, $point[1]), $prime); + + if (gmp_cmp($denom, 0) < 0) { + $denom = gmp_add($denom, $prime); + } + + $slope = gmp_mod(gmp_mul($num, gmp_invert($denom, $prime)), $prime); + $x3 = gmp_mod(gmp_sub(gmp_mul($slope, $slope), gmp_mul(2, $point[0])), $prime); + $y3 = gmp_mod(gmp_sub(gmp_mul($slope, gmp_sub($point[0], $x3)), $point[1]), $prime); + + if (gmp_cmp($x3, 0) < 0) { + $x3 = gmp_add($x3, $prime); + } + + if (gmp_cmp($y3, 0) < 0) { + $y3 = gmp_add($y3, $prime); + } + + return [$x3, $y3]; + } }