作者:林冠宏 / 指尖下的幽靈。轉載者,請: 務必標明出處。
GitHub : https://github.com/af913337456/
出版的書籍:
要了解 Nostr,得從頭開始
其實 Nostr 的整體思想並不算新穎,提出的時間也是好幾年前了。最近這年由於 web3 應用概念
的火爆導致它被挖出來並廣為人知。
三年前的基於 Golang 的 client ,noscl
可跳過
]我們固有的 C/S 服務思維
是 client 發請求,server 相應結果。
當簡簡單單的時候,就是如圖下面的情況:
特徵:低處理量,低可用,資料高內聚
當用戶量越來越多,產品做大了的情況,server 就是一個叢集,裡面有各種小 server。
這種時候的叢集服務所有權是集中的,集中於一個組織或多個組織。不夠去中心化。
特徵:高處理量,高可用,高耦合,資料可分散式儲存,仍然中心化
再後面,區塊鏈出現了,此時的服務要走去中心化的線路,服務不再叫做服務,而是節點
,說法雖然不一樣了,但本質還是提供資料處理、儲存、整理返回
給 client 的功能。如下圖:
這是一種更加複雜的 C/S 通訊模式:
特徵:區塊鏈特徵
如果你看了上面 服務的演變
小節,可以思考下,服務的架構的大方向還可以怎麼變?我們把區塊鏈節點的的服務圖做一些修改變成下面這圖:
去掉 Node 之間的 P2P 通訊。變成:
這就是 Nostr 協定所描述的服務架構。在 Nostr 中,沒有了 server 的稱呼,變成了 Relay。就像區塊鏈一樣,把 server 變成了 node。
產生了一個核心問題:
資料如何同步?比如 Client-A 如何看到 Client-B 發的資訊/訊息?
對於這個問題,如果 Relay 之間有 P2P 通訊,那麼就像區塊鏈節點那樣,資料相互同步便解決了。但 Nostr 所描述的做法卻是這樣的:
單機
,於是乎,A 或 B 兩個使用者,就會:
使用者要使用 Nostr 應用,得滿足2個條件:
為了解決這些問題,現在 Nostr 衍生出的產品中就有公共 Relay
,比如:snort.social,這些 Relay 提供註冊功能
,所謂的註冊就是在他的網站生成金鑰對
。然後如果使用者沒錢、沒技術自己搭建 Relay 伺服器,就可以訂閱它的 Relay。
這樣的話,所有訂閱者都可以在網站頁面展示出來,讓後來的人看到,等於直接看到其他使用者,在這些使用者中找好友來進行第一次的談話。
但是如果沒有自己的 Relay 的話,資料其實變相儲存在別人的伺服器。
技術實現方面,這裡要注意一個點:
某一 Client 所有訂閱了的 Relay,在 Client 發資料的時候,需要給所有訂閱了的 Relay 都發。並非強制,但協定要求。
Nostr 與區塊鏈關係不大
。
Nostr 協定就像 Http 協定一樣,制定了 C/S 的通訊形式。可以使用任何程式語言去實現。下面我將選擇幾個有代表性
的部分來講解下,最好是去看協定檔案。
官方檔案:nostr-protocol
Nostr 的客戶賬號,不需要依賴 Relay,可以在 Client 本地直接生成。就是 BTC
的錢包。
比如這段程式碼就是生成個 Nostr 使用者端賬戶,和 BTC 的錢包生成
一樣:
// 完整的,見文章頭部 git 專案
// 從私鑰獲取公鑰
func getPubKey(privateKey string) string {
keyb, _ := hex.DecodeString(privateKey)
_, pubkey := btcec.PrivKeyFromBytes(keyb)
return hex.EncodeToString(schnorr.SerializePubKey(pubkey))
}
func keyGen(opts docopt.Opts) {
seedWords, _ := nip06.GenerateSeedWords() // 助記詞
seed := nip06.SeedFromWords(seedWords)
sk, _ := nip06.PrivateKeyFromSeed(seed) // 私鑰
fmt.Println("seed:", seedWords)
fmt.Println("private key:", sk)
fmt.Println("pubkey:", getPubKey(sk)) // 公鑰
}
上面程式碼執行結果:
seed: arrow suspect reunion hire project damp protect comic leopard market repair diet delay direct bid mountain rigid sister moral speed cloud dawn rain vanish
private key: 3e6d9287d017b5ca1a1219b9d403d172f5ee2df74e112e7d890b070939d1fdb4
pubkey: cb6fd58aa73d01f4e7f803ae41f80caabe2d68288f19a231a6e57571db6a1eb4
[命令,引數,引數...]
釋出命令:["EVENT", <event JSON>]
要注意,事件具體要完成什麼動作,完全看裡面的引數 kind
,見下面的kind 附錄
小節,可見 kind = 1
的時候,代表傳送的是短文,等於發文字貼文。
["EVENT",
{
"id": "21e1b711fa6a9741ab7d134d2ea5a2e6ac6c75751386b411c46438118a4c0dd4",
"pubkey": "86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4",
"created_at": 1679112916,
"kind": 1, // 發文字貼文
"tags": [
["e",<event_id>,<other_1>,<other_2>],
["p",<pubkey>],
....
],
"content": "9988cc",
"sig": "883af7863a63d55c207e272707894944ee763810cd0393d344f85dc9a0bc624c4cc1b28ca483008729b56602f4acaad22e084c2b44db02ecc11e238880b8fd62"
}
]
id
就是當前請求的 id,生成方式是對 event 整體內容進行 sha256.Sum256
計算得到,這些都可以在檔案中得知;pubkey
就是傳送者 sender;sig
就是整個 event 的簽名,防止內容被篡改,在 Relay 端,會對接收到的 event 驗籤;kind
直接理解為 event 的型別,告訴 Relay 要達到什麼目的;tags
純粹的標籤陣列,為了攜帶輔助引數來實現功能而設立,下面我列舉兩個場景來說明這個引數的靈活使用,要注意,這個引數要實現什麼功能,是沒固定說法的,思維在這裡要靈活
。釋出內容參照到其他內容的時候
。可以在 tags 中的 e 標籤陣列內新增其他 event 的 id;刪除自己所釋出的 event 的時候
。可以在 tags 的 e 標籤中新增想要被刪除的 eventId;發私信
。私信的 kind 是 4
,此時 content 是加密的,只有接收方能解密,此時 tags 的 p 標籤中,就是要接收私信人的 pubkey訂閱操作的完成可以達成兩個目的:
訂閱命令:["REQ", <subscription_id>, <filters JSON>]
[ "REQ",
"b326655084f5f1", // 一次性隨機生成的請求 id
{
"ids": ["ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3"],
"authors": ["86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4"],
"kinds": [4], // 標明只載入私信的
"#e": ["9c0c22f940bc5e8bc397206a3a3566e01eccf"], // 同時參照了這個推文
"#p": ["86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4"],
"since": 1679112916,
"until": 1679112996,
"limit": 7
}
]
可以看到,REQ 訂閱操作的引數和 EVENT 的大同小異,一樣具有很強的靈活性。查詢的時候所有引數取且操作
。
ids
,指定基於 event 的變化,不指定就是全量訂閱;authors
,要訂閱這個作者發的,不指定就沒這個限制;對應到訂閱,自然有取消訂閱,取消後,Relay 不再推播 event 資料。
取消訂閱命令:["CLOSE", <subscription_id>]
此 subscription_id
就是訂閱時候的那個 id。
授權請求在 Nostr 中是個更加高度自定義的動作,根據協定描述,這個請求用於拓展 Relay 的一些主觀功能。
首先我們知道,Relay 在整個 Nostr 網路中是會存在很多的,那麼就不排除有一些 Relay 代表了一些組織,它們可能只向特定的使用者開放,比如說你要接入就要付費
或者收到邀請碼
等前提。
Relay 如何實現這些限制呢?答案就是使用 AUTH。
授權命令:["AUTH", <signed-event-json>]
["AUTH", {
"id": "...",
"pubkey": "...",
"created_at": 1669695536,
"kind": 22242, // 固定,必須是 22242
"tags": [
["relay", "wss://relay.example.com/"], // 目標 relay url,會做校驗
["challenge", "challengestringhere"] // relay 返回再放進來
],
"content": "",
"sig": "..."
}]
下面我將使用時序圖
來說明 Client 和 Relay 是如何進行授權動作的。
challenge code
;challenge code
後得知此 Relay 是要授權接入的,否則一些功能無法使用;challenge code
和其他資訊,並儲存相關資料;整個 AUTH 的流程是比較簡單的。最核心的資訊是 challenge code
,完全可以理解為驗證碼
,具體怎麼去實現這一部分,完全可以由 Relay 自定義
。我上面舉的例子是現在 demo 原始碼的做法,現實中是可以但不限於
下面的拓展:
challenge code
,再去輸入;challenge code
只能用一次,且 10 分鐘有效。challenge code 的驗證程式碼如下,所關聯的引數一目瞭然:
// ValidateAuthEvent checks whether event is a valid NIP-42 event for given challenge and relayURL.
// The result of the validation is encoded in the ok bool.
func ValidateAuthEvent(event *nostr.Event, challenge string, relayURL string) (pubkey string, ok bool) {
if event.Kind != 22242 {return "", false}
if event.Tags.GetFirst([]string{"challenge", challenge}) == nil {
return "", false
}
expected, err := parseUrl(relayURL)
if err != nil {return "", false}
found, err := parseUrl(event.Tags.GetFirst([]string{"relay", ""}).Value())
if err != nil {return "", false}
if expected.Scheme != found.Scheme ||
expected.Host != found.Host ||
expected.Path != found.Path {
return "", false
}
now := time.Now()
if event.CreatedAt.After(now.Add(10*time.Minute)) || event.CreatedAt.Before(now.Add(-10*time.Minute)) {
return "", false
}
if ok, _ := event.CheckSignature(); !ok {return "", false}
return event.PubKey, true
}
["EVENT", <subscription_id>, <event JSON>]
,id 就是訂閱時發過來的 id;["NOTICE", <message>]
;["EOSE", <subscription_id>]
;["AUTH", <challenge-string>]
["OK", <event_id>, <true|false>, <message>]
NOTICE
和 OK
的區別是:
例子:
// 驗證演演算法:https://github.com/nostr-protocol/nips/blob/master/42.md
// 主要就是驗證 client 的 challenge 是否是 relay 返回的。這個 relay 的實現是隨機搞的 challenge
// 拓展的做法就是可以根據更豐富的演演算法生成 challenge 再返回。比如與 pubkey、時間加密後返回一個
// 特定的,在多少天內有效的,只能這個 pubkey 存取的 challenge 碼
if pubkey, ok := nip42.ValidateAuthEvent(&evt, ws.challenge, auther.ServiceURL()); ok {
ws.authed = pubkey
ws.WriteJSON([]interface{}{"OK", evt.ID, true, "authentication success"})
} else {
ws.WriteJSON([]interface{}{"OK", evt.ID, false, "error: failed to authenticate"})
}
這裡我將基於 Golang 實現的 Relay 和 Client 來做一下 Nostr 的簡單互動演示。具體的專案見文頭的專案。
Relay 專案地址: https://github.com/fiatjaf/relayer
進入到 basic 目錄,將 main.go 檔案的內容改成下面的:
package main
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/fiatjaf/relayer"
"github.com/fiatjaf/relayer/storage/postgresql"
"github.com/fiatjaf/relayer/storage/sqlite3"
"github.com/kelseyhightower/envconfig"
"github.com/nbd-wtf/go-nostr"
)
type SqliteRelay struct {
SQLiteDatabase string `envconfig:"SQLITE_DATABASE"`
storage *sqlite3.SQLite3Backend
}
func (r *SqliteRelay) Name() string {
return "SQLiteRelay"
}
func (r *SqliteRelay) Storage() relayer.Storage {
return r.storage
}
func (r *SqliteRelay) OnInitialized(*relayer.Server) {}
func (r *SqliteRelay) Init() error {
return nil
}
func (r *SqliteRelay) AcceptEvent(evt *nostr.Event) bool {
jsonb, _ := json.Marshal(evt)
if len(jsonb) > 10000 {
return false
}
return true
}
func main() {
runSQLiteRelay()
}
func runSQLiteRelay() {
r := SqliteRelay{}
if err := envconfig.Process("", &r); err != nil {
log.Fatalf("failed to read from env: %v", err)
return
}
r.storage = &sqlite3.SQLite3Backend{DatabaseURL: "jdbc:sqlite:identifier.sqlite"}
if err := relayer.StartConf(relayer.Settings{
Host: "127.0.0.1",
Port: "8888",
}, &r); err != nil {
log.Fatalf("server terminated: %v", err)
}
}
上面程式碼啟動後就會啟動原生的 Relay 服務,RelayUrl:http://127.0.0.1:8888
,它使用 sqlite3 資料庫來儲存資料,sqlite3 是庫裡面自己支援了的。如果不使用這個,需要自己實現其他資料庫的版本,根據介面函數來實現即可,難度並不大。
Client 可以使用下面簡單的例子,直接進行測試通訊。完成傳送 event 和 req 訂閱命令的功能。
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/nbd-wtf/go-nostr"
)
func main() {
ctx := context.Background()
pubkey := "86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4"
privateKey := "8d137174f49cce2590d3a30f89a9dd9865b319ef3a94e24b37cfa106ff85259c"
// 載入並訂閱
// sendREQ(ctx, pubkey, privateKey)
// 發文字貼文
sendEVENT(ctx, pubkey, privateKey, "hello world")
}
func newRelay(ctx context.Context) *nostr.Relay {
relay, err := nostr.RelayConnect(ctx, "http://127.0.0.1:8888")
if err != nil {
panic(fmt.Errorf("failed to connect to relay: %w", err))
}
return relay
}
func sendREQ(ctx context.Context, pubkey, privateKey string) {
filter := nostr.Filter{
IDs: nil,
Kinds: []int{nostr.KindTextNote}, // 只訂閱短文
Authors: []string{pubkey}, // 只訂閱這個作者發的
Tags: nil,
Since: nil,
Until: nil,
Limit: 100,
}
req := newRelay(ctx).PrepareSubscription()
req.Sub(ctx, nostr.Filters{filter})
// 在這裡接受所有的返回並列印
for event := range req.Events { // Events 會在 unsub 函數內被關閉
bys, _ := event.MarshalJSON()
fmt.Println("receive event:-------", string(bys))
}
}
func sendEVENT(ctx context.Context, pubkey, privateKey, content string) {
helloTxtEvent := nostr.Event{
ID: "", // signEventAndCalculateID 中賦值
PubKey: pubkey,
CreatedAt: time.Now(),
Kind: nostr.KindTextNote, // 1 短文
Tags: nil, // 我們沒其他的功能,這裡 tag 留空
Content: content,
Sig: "", // signEventAndCalculateID 中賦值
}
if err := signEventAndCalculateID(&helloTxtEvent, privateKey); err != nil {
panic(fmt.Errorf("signEventAndCalculateID err: %w", err))
}
if sendStatus, err := newRelay(ctx).Publish(ctx, helloTxtEvent); err != nil {
panic(fmt.Errorf("publish err: %w", err))
} else {
bys, _ := json.Marshal(helloTxtEvent)
fmt.Println(fmt.Sprintf("send event status [%s] event data: %s", sendStatus, string(bys)))
}
}
func signEventAndCalculateID(evt *nostr.Event, privateKey string) error {
h := sha256.Sum256(evt.Serialize())
s, err := hex.DecodeString(privateKey)
if err != nil {
return fmt.Errorf("Sign called with invalid private key '%s': %w", privateKey, err)
}
sk, _ := btcec.PrivKeyFromBytes(s)
sig, err := schnorr.Sign(sk, h[:])
if err != nil {
return err
}
evt.ID = hex.EncodeToString(h[:]) // id 賦值
evt.Sig = hex.EncodeToString(sig.Serialize()) // 生成簽名資訊
return nil
}
執行上面 Client 程式碼 main 函數中的 sendEVENT
可以在控制檯看到傳送成功:
send event status [success] event data: {"id":"d80f68baee57f2cbb231f44df27ddbf22728b9649083386a44f01cdf159b398a","pubkey":"86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4","created_at":1679144385,"kind":1,"tags":[],"content":"hello world","sig":"fdb4570c73cfded2c8a29b8ca926a5a400b81246b676f5090651949806f3741c40d2df8542c70f6dbdbff4ada3123cb285fcdcb0fdf499b2bd41a97ed5f4f3bb"}
再執行 sendREQ
函數,進行訂閱可以前面傳送過的資料:
receive event:------- {"id":"d80f68baee57f2cbb231f44df27ddbf22728b9649083386a44f01cdf159b398a","pubkey":"86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4","created_at":1679144385,"kind":1,"tags":[],"content":"hello world","sig":"fdb4570c73cfded2c8a29b8ca926a5a400b81246b676f5090651949806f3741c40d2df8542c70f6dbdbff4ada3123cb285fcdcb0fdf499b2bd41a97ed5f4f3bb"}
receive event:------- {"id":"c0965330b8f703cdb24916f8c74e54264ecdfebe098e79c7bec0e5586bd1ec9a","pubkey":"86ab84ff172fa8104bf9c0c22f940bc5e8bc397206a3a3566e01eccf9a05ffa4","created_at":1679143318,"kind":1,"tags":[],"content":"hello world","sig":"3d4636b45fff22a984b9cd5b5e56fec4fcc065455a2c049441f67512d1c7a6d78608dfd8a53a0fd6540ec63c3cde1d0553c8471811ac7068042116ede258754c"}
除此之外,還可以直接在 sqlite3 中檢視表格看到資料。
至此,我們嘗試了整個基於 Nostr 協定的完整應用。
kind | description | NIP |
---|---|---|
0 | Metadata | 1 |
1 | Short Text Note | 1 |
2 | Recommend Relay | 1 |
3 | Contacts | 2 |
4 | Encrypted Direct Messages | 4 |
5 | Event Deletion | 9 |
7 | Reaction | 25 |
8 | Badge Award | 58 |
40 | Channel Creation | 28 |
41 | Channel Metadata | 28 |
42 | Channel Message | 28 |
43 | Channel Hide Message | 28 |
44 | Channel Mute User | 28 |
1984 | Reporting | 56 |
9734 | Zap Request | 57 |
9735 | Zap | 57 |
10000 | Mute List | 51 |
10001 | Pin List | 51 |
10002 | Relay List Metadata | 65 |
22242 | Client Authentication | 42 |
24133 | Nostr Connect | 46 |
30000 | Categorized People List | 51 |
30001 | Categorized Bookmark List | 51 |
30008 | Profile Badges | 58 |
30009 | Badge Definition | 58 |
30023 | Long-form Content | 23 |
30078 | Application-specific Data | 78 |
1000-9999 | Regular Events | 16 |
10000-19999 | Replaceable Events | 16 |
20000-29999 | Ephemeral Events | 16 |
30000-39999 | Parameterized Replaceable Events | 33 |
本文一些內容由下面小程xu搜尋提供幫助。