細節講解並實操下: 去中心化社交協定 ---- Nostr

2023-03-21 12:02:56

作者:林冠宏 / 指尖下的幽靈。轉載者,請: 務必標明出處。

GitHub : https://github.com/af913337456/

出版的書籍:


要了解 Nostr,得從頭開始


目錄

  • 服務的演變
  • Nostr 的服務形態
  • Relay 的侷限性
  • Nostr 與區塊鏈的關係
  • Nostr 協定內容選集
    • 賬戶部分
    • 資料操作部分
      • Client 釋出一個事件
      • tags 引數使用例子
      • Client 發訂閱請求
      • Client 取消訂閱
      • Client 發授權請求
      • Relay 返回的命令
    • 實操及觀察
      • 部署 Relay 服務
      • 使用 Client 與 Relay 互動
      • 檢視資料
    • kind 附錄
  • 鳴謝

其實 Nostr 的整體思想並不算新穎,提出的時間也是好幾年前了。最近這年由於 web3 應用概念的火爆導致它被挖出來並廣為人知。

三年前的基於 Golang 的 client ,noscl

服務的演變 [可跳過]

我們固有的 C/S 服務思維是 client 發請求,server 相應結果。

當簡簡單單的時候,就是如圖下面的情況:

特徵:低處理量,低可用,資料高內聚

當用戶量越來越多,產品做大了的情況,server 就是一個叢集,裡面有各種小 server。

這種時候的叢集服務所有權是集中的,集中於一個組織或多個組織。不夠去中心化。

特徵:高處理量,高可用,高耦合,資料可分散式儲存,仍然中心化

再後面,區塊鏈出現了,此時的服務要走去中心化的線路,服務不再叫做服務,而是節點,說法雖然不一樣了,但本質還是提供資料處理、儲存、整理返回給 client 的功能。如下圖:

這是一種更加複雜的 C/S 通訊模式:

  1. Client 可以與任一個 Node 通訊;
  2. 區塊鏈節點 Node 分佈在各地,誰都部署一個 Node Server 然後加入到節點網路;
  3. Node 節點相互 P2P 通訊;
  4. 整個拓撲比例:N:Y:Z

特徵:區塊鏈特徵

Nostr 的服務形態 [核心]

如果你看了上面 服務的演變 小節,可以思考下,服務的架構的大方向還可以怎麼變?我們把區塊鏈節點的的服務圖做一些修改變成下面這圖:

去掉 Node 之間的 P2P 通訊。變成:

  1. Client 可以與 單個 或 多個 Relay 通訊;
  2. Client 各自獨立,不能相互通訊;
  3. Relay 有多個,且分佈在不同地方;
  4. Relay 之間沒任何通訊,各自獨立;

這就是 Nostr 協定所描述的服務架構。在 Nostr 中,沒有了 server 的稱呼,變成了 Relay。就像區塊鏈一樣,把 server 變成了 node。

產生了一個核心問題:

資料如何同步?比如 Client-A 如何看到 Client-B 發的資訊/訊息?

對於這個問題,如果 Relay 之間有 P2P 通訊,那麼就像區塊鏈節點那樣,資料相互同步便解決了。但 Nostr 所描述的做法卻是這樣的:

  1. Client-A 給 Relay-A 發資料,Relay-A 儲存在本地;
  2. Client-B 給 Relay-B 發資料,Relay-B 儲存在本地;
  3. 因為要交流,否則就是玩單機,於是乎,A 或 B 兩個使用者,就會:
    1. 在某些中心化社交平臺暴露自己的 Relay 的存取 Url;
    2. 用某其他軟體私聊互發 Relay 的 Url;
  4. 在 3 的基礎上,Client-A 或 Client-B 就可以在 Nostr 協定所實現的 Client 端軟體上載入到對方 Relay 儲存的資料,也就達到了看見/交流的目的。

Relay 的侷限性

使用者要使用 Nostr 應用,得滿足2個條件:

  1. 下載 Nostr 使用者端軟體,這個已有,網上可搜;
  2. 訂閱 Relay,這個可以:
    1. 自己買個伺服器,找份 Relay 程式碼,編譯個可執行檔案,啟動服務,然後連線進來,就可以發資料到它存起來。然後再去找其他朋友的 Relay 連結;
    2. 去網上找公開的 Relay 連結,訂閱進去,從而可以發資料,看資料;

為了解決這些問題,現在 Nostr 衍生出的產品中就有公共 Relay,比如:snort.social,這些 Relay 提供註冊功能,所謂的註冊就是在他的網站生成金鑰對。然後如果使用者沒錢、沒技術自己搭建 Relay 伺服器,就可以訂閱它的 Relay。

這樣的話,所有訂閱者都可以在網站頁面展示出來,讓後來的人看到,等於直接看到其他使用者,在這些使用者中找好友來進行第一次的談話。

但是如果沒有自己的 Relay 的話,資料其實變相儲存在別人的伺服器。

技術實現方面,這裡要注意一個點:

某一 Client 所有訂閱了的 Relay,在 Client 發資料的時候,需要給所有訂閱了的 Relay 都發。並非強制,但協定要求。

Nostr 與區塊鏈的關係

Nostr 與區塊鏈關係不大

  1. Nostr 不是某公鏈的 DApp;
  2. Nostr 沒涉及到智慧合約;
  3. Nostr 沒涉及鏈上請求;
  4. Nostr 在使用者賬戶部分用了和 BTC 一樣的公私鑰生成演演算法;
  5. Nostr 和其他區塊鏈的 DApp 擁有一樣的 web3 概念

Nostr 協定內容選集

Nostr 協定就像 Http 協定一樣,制定了 C/S 的通訊形式。可以使用任何程式語言去實現。下面我將選擇幾個有代表性的部分來講解下,最好是去看協定檔案。

官方檔案:nostr-protocol

賬戶部分

Nostr 的客戶賬號,不需要依賴 Relay,可以在 Client 本地直接生成。就是 BTC 的錢包。

  1. 私鑰充當了密碼;
  2. 公鑰充當了賬號

比如這段程式碼就是生成個 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
資料操作部分
  1. client 和 relay 採用 websocket 的協定傳輸資料;
  2. 資料格式是 Json;
  3. 標準格式是:[命令,引數,引數...]
Client 釋出一個事件

釋出命令:["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"
	}
]
  1. id 就是當前請求的 id,生成方式是對 event 整體內容進行 sha256.Sum256計算得到,這些都可以在檔案中得知;
  2. pubkey 就是傳送者 sender;
  3. sig 就是整個 event 的簽名,防止內容被篡改,在 Relay 端,會對接收到的 event 驗籤;
  4. kind 直接理解為 event 的型別,告訴 Relay 要達到什麼目的;
  5. tags 純粹的標籤陣列,為了攜帶輔助引數來實現功能而設立,下面我列舉兩個場景來說明這個引數的靈活使用,要注意,這個引數要實現什麼功能,是沒固定說法的,思維在這裡要靈活
tags 引數使用例子
  1. 場景:釋出內容參照到其他內容的時候。可以在 tags 中的 e 標籤陣列內新增其他 event 的 id;
  2. 場景:刪除自己所釋出的 event 的時候。可以在 tags 的 e 標籤中新增想要被刪除的 eventId;
  3. 場景:發私信。私信的 kind 是 4,此時 content 是加密的,只有接收方能解密,此時 tags 的 p 標籤中,就是要接收私信人的 pubkey
Client 發訂閱請求

訂閱操作的完成可以達成兩個目的:

  1. 只要 websocket 不關閉,只要 Relay 有新的 event 接收到,就會推播到 Client;
  2. 訂閱開始的時候,Relay 會對當前的訂閱請求返回一次目標 event 資料;

訂閱命令:["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 的大同小異,一樣具有很強的靈活性。查詢的時候所有引數取且操作

  1. ids,指定基於 event 的變化,不指定就是全量訂閱;
  2. authors,要訂閱這個作者發的,不指定就沒這個限制;
Client 取消訂閱

對應到訂閱,自然有取消訂閱,取消後,Relay 不再推播 event 資料。

取消訂閱命令:["CLOSE", <subscription_id>]

subscription_id 就是訂閱時候的那個 id。

Client 發授權請求

授權請求在 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 是如何進行授權動作的。

  1. Client 在和 Relay 建立連結的時候,Relay 如果實現了 AUTH 功能,就需要給 Client 返回個 challenge code;
  2. Client 拿到 challenge code 後得知此 Relay 是要授權接入的,否則一些功能無法使用;
  3. Client 打包 AUTH 請求命令,把發出;
  4. Relay 驗證 challenge code 和其他資訊,並儲存相關資料;

整個 AUTH 的流程是比較簡單的。最核心的資訊是 challenge code,完全可以理解為驗證碼,具體怎麼去實現這一部分,完全可以由 Relay 自定義。我上面舉的例子是現在 demo 原始碼的做法,現實中是可以但不限於下面的拓展:

  1. 使用者在 A 網站購買 challenge code,再去輸入;
  2. 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
}
Relay 返回的命令
  1. 訂閱之後,給 Client 推播資料。["EVENT", <subscription_id>, <event JSON>],id 就是訂閱時發過來的 id;
  2. 給 Client 的請求報錯或提示。["NOTICE", <message>]
  3. 告知 Client 當前 Relay 所有儲存的 event 都已經返回。["EOSE", <subscription_id>]
  4. 告知 Client,此 Relay 需要授權,並返回 challenge_code。
    • ["AUTH", <challenge-string>]
  5. 告知 Client 某事件的結果,成功或失敗。["OK", <event_id>, <true|false>, <message>]

NOTICEOK 的區別是:

  1. NOTICE 多用於請求並未完成,比如引數結構、缺少類的錯誤
  2. 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 服務

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 與 Relay 互動

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 附錄

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搜尋提供幫助。