细节讲解并实操下: 去中心化社交协议 ---- Nostr

2,216 阅读16分钟

作者:林冠宏 / 指尖下的幽灵。转载者,请: 务必标明出处。

掘金:juejin.im/user/178526…

GitHub : github.com/af913337456…

出版的书籍:


要了解 Nostr,得从头开始


目录

  • 服务的演变
  • Nostr 的服务形态
  • Relay 的局限性
  • Nostr 与区块链的关系
  • Nostr 协议内容选集
    • 账户部分
    • 数据操作部分
      • Client 发布一个事件
      • tags 参数使用例子
      • Client 发订阅请求
      • Client 取消订阅
      • Client 发授权请求
      • Relay 返回的命令
    • 实操及观察
      • 部署 Relay 服务
      • 使用 Client 与 Relay 交互
      • 查看数据
    • kind 附录
  • 鸣谢

其实 Nostr 的整体思想并不算新颖,提出的时间也是好几年前了。最近这年由于 web3 应用概念的火爆导致它被挖出来并广为人知。

三年前的基于 Golang 的 client ,noscl

image.png

服务的演变 [可跳过]

我们固有的 C/S 服务思维是 client 发请求,server 相应结果。

当简简单单的时候,就是如图下面的情况:

image.png

特征:低处理量,低可用,数据高内聚

当用户量越来越多,产品做大了的情况,server 就是一个集群,里面有各种小 server。

image.png

这种时候的集群服务所有权是集中的,集中于一个组织或多个组织。不够去中心化。

特征:高处理量,高可用,高耦合,数据可分布式存储,仍然中心化

再后面,区块链出现了,此时的服务要走去中心化的线路,服务不再叫做服务,而是节点,说法虽然不一样了,但本质还是提供数据处理、存储、整理返回给 client 的功能。如下图:

image.png

这是一种更加复杂的 C/S 通讯模式:

  1. Client 可以与任一个 Node 通讯;
  2. 区块链节点 Node 分布在各地,谁都部署一个 Node Server 然后加入到节点网络;
  3. Node 节点相互 P2P 通讯;
  4. 整个拓扑比例:N:Y:Z

特征:区块链特征

Nostr 的服务形态 [核心]

如果你看了上面 服务的演变 小节,可以思考下,服务的架构的大方向还可以怎么变?我们把区块链节点的的服务图做一些修改变成下面这图:

image.png

去掉 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 是如何进行授权动作的。

image.png

  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 项目地址: github.com/fiatjaf/rel…

进入到 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 附录

kinddescriptionNIP
0Metadata1
1Short Text Note1
2Recommend Relay1
3Contacts2
4Encrypted Direct Messages4
5Event Deletion9
7Reaction25
8Badge Award58
40Channel Creation28
41Channel Metadata28
42Channel Message28
43Channel Hide Message28
44Channel Mute User28
1984Reporting56
9734Zap Request57
9735Zap57
10000Mute List51
10001Pin List51
10002Relay List Metadata65
22242Client Authentication42
24133Nostr Connect46
30000Categorized People List51
30001Categorized Bookmark List51
30008Profile Badges58
30009Badge Definition58
30023Long-form Content23
30078Application-specific Data78
1000-9999Regular Events16
10000-19999Replaceable Events16
20000-29999Ephemeral Events16
30000-39999Parameterized Replaceable Events33

鸣谢

本文一些内容由下面小程xu搜索提供帮助。