Nostrリレーを非依存で開発するには
概要
筆者は去年(2024年)からNostrリレーを非依存で作れないかと考えていて、少しずつ進めています。
そのメモ書きのようなものです。
この記事は随時更新していきます。
非依存てどういうこと?
ここでの「非依存」は、言語標準で提供しているパッケージ以外を使用しないことを意味します。
非依存で開発したら、何が嬉しいか
Nostrリレーは後述するいくつもの技術要素を組み合わせて出来ています。
自作できたら、それらの要素をNostr用に最適化して高速化したりできます。
Nostrとは
Nostrの説明
プロトコル仕様(NIP: Nostr Implementation Possibilities)
有志のNIP日本語翻訳
有志がscrapboxに情報を纏めています
対応が必要なNIP
リレーを公開するために最低限対応が必要なのは、NIP-01だけです。
NIP-01: Basic protocol flow description
ただ、リレー情報を返却するNIP-11も対応しておいた方がよいでしょう。
NIP-11: Relay Information Document
ちなみに、NIP-01はリレーもクライアントも対応必須な基本のNIPですが、2025年2月現在、まだdraftです。
Nostrリレー開発に必要な技術要素
ここからは、リレー開発に必要な技術要素を紹介します。
Nostrリレーに必要な技術要素は、ざっとこんな感じです。
Nostrリレー
├── データ送受信
│ └── WebSocket
│ ├── TCP/IP通信
│ ├── TLS通信
│ └── HTTPハンドシェイク
│ └── accept key生成
│ ├── sha1
│ └── base64
├── データ解析
│ └── EVENTの解析
│ │ └── json
│ └── idの検証
│ └── sha256
│ └── 電子署名(sig)の検証
│ └── secp256k1を用いたSchnorr署名
└── データ永続化
WebSocket
WebSocketはRFCで定義されている、TCP/IPコネクション上で双方向通信を可能とする通信プロトコルです。
NostrはWebSocketの上で動作するプロトコルのため、WebSocketが必要です。
JavaやC#など、言語標準で提供している言語もいくつかあります。
後述するTLSの自作を考えなければ、Nostrに必要なWebSocketサーバーを実装するのは、それほど難しくありません。
筆者の実装もおいておきます。(2000行を少し超えるくらい)
(Hakkadaikon/websocket)
TCP/IP通信にBSDソケットを使う前提で、WebSocketサーバーの大まかな処理の流れを解説します:
WebSocketサーバーの処理(BSDソケットを使う場合)
1. TCPソケットを開く (socket/bind/listen)
2. クライアントから接続要求があった時に、受け入れる (accept)
3. クライアントからHTTPによるハンドシェイクを受信する (recv)
以下のようなハンドシェイクを受信します。
サンプル(RFC6455より)
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
最初の1行をHTTPリクエストラインで、それ以下はHTTPヘッダです。
各行はCRLFで改行されています。
詳しいバリデーションについてはRFC6455を参照ください。
4. クライアントにハンドシェイクを送信 (send)
クライアントに、以下のハンドシェイクメッセージを送信します。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: [後述]
Sec-WebSocket-Acceptは、以下の手順で作成します:
- Sec-WebSocket-Keyの末尾にGUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11) を追加
- sha1ハッシュを取得
- base64エンコードする
5. 接続完了
クライアント側がサーバーのSec-WebSocket-Acceptを検証し、問題なければ接続確立です。
6. WebSocketパケットのやりとり
ここからは、WebSocketパケットのやりとりになります。
WebSocketパケットは、以下の様になっています。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
0bit: FIN
0なら後述のWebSocketパケットのペイロードと連結する必要があります。
1なら最終パケットです。
Nostrの場合、分割するほど大きなEVENTはあまり無いため、基本的には1が入ると考えて良いです。
1-3bit: RSV
予約ビットです。無視して構いません。
4-7bit: opcode
WebSocketパケットの種類を示します。
0x1: テキストフレーム
ペイロードがテキストであることを示します。
NostrのEVENTは全てテキストフレームになります。
0x2: バイナリフレーム
ペイロードがバイナリデータであることを示します。
Nostrの場合、使用しません。
0x8: closeフレーム
closeフレームが到着した場合、クライアントとの接続を切断する必要があります。
0x9: pingフレーム
生存確認のためのフレームです。
サーバーはこのフレームを受信したら、クライアントにpongフレームを返送する必要があります。
0xA: pongフレーム
pingフレームを受信したら返送するフレームです。
8bit: MASK
ペイロードがマスクされているかどうかのフラグです。
1の場合は、ペイロードがマスクされています。
RFC6455には、以下ルールが記載されています。
- クライアント->サーバーに送るデータ: マスクする
- サーバー->クライアントに送るデータ: マスクしない
9-15bit: Payload Len
ペイロード長を示します。
- 125以下: Payload Lenの長さが、そのままペイロード長になる (Ext Payload Lenなし)
- 126 : ペイロード長が2バイト(Ext Payload Lenが2バイト)
- 127 : ペイロード長が8バイト(Ext Payload Lenが8バイト)
16-32bit / 16bit-80bit : Ext Payload Len
ペイロード長が1バイトに収まらなかった時の、拡張ペイロード長です。(最大8バイト)
16-48bit / 32-64bit / 80-112bit : masking key
マスクされている場合のマスキングキー(4バイト)を示します。
Payload
WebSocketのペイロードです。
マスクされている場合、ペイロードの各バイト列に以下の操作をしてマスクを解除する必要があります。
ペイロード[ペイロードNo] = ペイロード[ペイロードNo] XOR マスキングキー[ペイロードNo % 4]
その他
厳密に実装しようとすると、定期的にpingフレームを接続しているクライアントに送信し、
それに対して一定時間応答がない場合は切断する機能などが必要です。
参考
TCP/IP通信
これを読んでいる方には説明するまでもないかもしれませんが、TCP/IPはRFC1122で定義されている通信プロトコルです。データの誤り訂正/輻輳制御/順序制御/再送制御などで通信の信頼性を高めています。
WebSocketは、TCP/IP通信の上に構築されています。
多くのプログラミング言語でTCP/IP通信の機能は標準搭載しており、Linux/Windows/macOSなどのOSはカーネルがTCP/IPプロトコルスタックの機能を有しています。(BSDソケットなどを介してシステムコールを呼べば使えます。)
そのため、自作するモチベは少ないかもしれません。
DPDKなどカーネルをバイパスするOSSを使う場合は、TCP/IPプロトコルスタックが別途必要になります。
また、TCP/IPは通信の基礎なので、学習にはなります。
近年だと、KLabさんがTCP/IPプロトコルスタックを自作するするための資料を作成していたので、自作する場合は参考になると思います。
参考
TLS通信
TLS(Transport Layer Security)はSSL(Secure Socket Layer)の後継で、RFCで定義されている暗号化プロトコルです。
インターネット上の通信を暗号化するために広く使われています。(HTTPS/Websocket/SMTP/VoIP/etc...)
基本的にはWebSocketで通信する際、TLSを使った暗号化をサポートする必要があります。
参考
json
Nostrのメッセージはjsonでやり取りされるため、jsonパーサーを用意する必要があります。
言語標準で組み込まれている言語もありますが、低水準な言語だと言語標準でjsonパーサーは用意されていないことも多いです。
JSONパーサーを自作している人は沢山いるので、自作する場合は先人達の記事を参考にするのが手っ取り早いかもしれません。
参考
base64
base64は、RFC4648で定義されているエンコード方式です。
前述のWebSocketで、Accept keyを生成する時などに使用します。
Wikipediaにもアルゴリズムが載っていて、比較的簡単に実装することができます。
参考
sha
shaは、アメリカ国立標準技術研究所(NIST)によって標準のハッシュ関数Secure Hash Standard(SHS)
に定義されている関数です。
sha1
前述のWebSocketで、HTTPハンドシェイク時にサーバーがAccept keyを生成するために使用します。
sha256
NostrのEVENTの"id"フィールドは、EVENTデータをシリアライズ後、sha256でハッシュ化した文字列が格納されています。(NIP-01参照)
Nostrリレーでは、このidが正しいか検証する必要があります。
shaは、言語標準でサポートしている言語もあれば、opensslなどの外部ライブラリもサポートしています。
参考
secp256k1によるSchnorr(シュノア)署名
secp256k1はStandards for Efficient Cryptographyで定義されている、楕円曲線の1つです。
Schnorr署名はISO/IEC標準で定められている、任意の楕円曲線上で動作する署名スキームです。
NostrではEVENTの"sig"フィールドに、secp256k1を用いたSchnorr署名で電子署名された文字列が格納されています。
Nostrリレーでは、この電子署名の内容を検証する必要があります。
参考
- secp256k1
- Schnorr署名
- ISO/IEC 14888-2
- secp256k1を用いたSchnorr署名
bech32(基本的に不要)
Nostrクライアントを使っていると、npub, nsec, nevent...などの表記を見かけることがありますが、あれがbech32形式の表記方法です。
しかし、通常のNostrリレーの実装の場合、 bech32は意識する必要はありません。
bech32は人間が読みやすいフォーマットで表現するための手法で、主にクライアントが意識します。
Nostrの電子署名(sig)や公開鍵(pubkey)は、通信する際にbech32形式ではなくHEX形式でやりとりされます。
参考
DB
NIPにはデータの保存期間や永続化についての仕様が無いので、DBによる永続化をしなくても基本的にはNIP違反とはなりません。
しかし、運用する上では基本的に永続化すると思います。
既存のNostrリレーの場合、SQLite/lmdb/Postgres/Redisなどが使われています。
真面目に1から実装するのは大変なので、永続化する場合は既存のOSSを使うことを考えた方がいいかも。
参考
余談
暗号/エンコードの自作について(TLS/sha1/base64/secp256k1/etc...)
暗号/エンコードライブラリの自作は、色々なことを考慮しなければいけません。
数学的な知識が必要になる
暗号/エンコードライブラリは、基本的に数学的な知識が必要になることも多いです。
特に、TLSやNostrの署名でも使われる楕円曲線暗号(ECC)などは、大学数学レベルの知識が必要になります。
セキュリティが特に求められる
性質上、暗号/エンコード関数に入力として渡される文字列は、パスワードなどの機微情報であることも多いです。
不具合がある場合、機微情報が漏洩する危険もあります。
広く使われるOSSで影響が大きい場合(OpenSSLなど)、CVEが発行されることもあります。
機微情報を扱うコーディングの世界では「メモリ上にも機微情報を残しておかない」というのが主流になっています。
機微情報が格納されたメモリをそのままにしておくと、別プロセスで意図せず参照されて機微上が漏洩する危険性もあります。
暗号/エンコード関数内で一時的な変数に機微情報を格納している場合は、最適化されない形で使い終わったメモリをクリアしておくのが良いです。
メモリ管理に関する有名なCVEとして、opensslのCVE-2014-0160(通称Heartbleed)があります。
参考:
速度の最適化が必要
暗号関数によっては、愚直に実装すると非常に時間のかかる関数もあります。
(特に、楕円曲線暗号などは愚直に実装すると実用に耐えないレベルで遅くなります。筆者が愚直にsecp256r1を実装した時は、計算が終わるまで1秒ほどかかってしまいました。)
opensslなど著名なOSSでは、Montgomery乗算など高速なアルゴリズムを用いたり
動作するCPU毎のアセンブリ・SIMD最適化などを行い、高速化を図っています。
暗号/エンコード自作まとめ
単なる学習目的で暗号/エンコードライブラリを作る場合はあまり意識しなくても良いですが、広く使ってもらうOSSを作ろうとすると、このあたりの考慮は必要になります。
不安ならopensslなどの外部ライブラリに頼るのが良いと思います。
(TLSの場合、言語標準でwebsocket通信がサポートされている場合は大概TLS通信もサポートされているので考慮不要です。)
過去に発行されたCVEを参照しておくことも有用かと。
終わりに
記事への指摘や参考情報があれば、本記事のコメント欄かNostrで教えてください。
小躍りしながら見に行きます。
Discussion
2025/2/4 余談を追加