细节讲解并实操下: 去中心化社交协议 ---- Nostr
作者:林冠宏 / 指尖下的幽灵。转载者,请: 务必标明出处。
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 通讯模式:
- Client 可以与任一个 Node 通讯;
- 区块链节点 Node 分布在各地,谁都部署一个 Node Server 然后加入到节点网络;
- Node 节点相互 P2P 通讯;
- 整个拓扑比例:N:Y:Z
特征:区块链特征
Nostr 的服务形态 [核心]
如果你看了上面 服务的演变
小节,可以思考下,服务的架构的大方向还可以怎么变?我们把区块链节点的的服务图做一些修改变成下面这图:
去掉 Node 之间的 P2P 通讯。变成:
- Client 可以与 单个 或 多个 Relay 通讯;
- Client 各自独立,不能相互通讯;
- Relay 有多个,且分布在不同地方;
- Relay 之间没任何通讯,各自独立;
这就是 Nostr 协议所描述的服务框架。在 Nostr 中,没有了 server 的称呼,变成了 Relay。就像区块链一样,把 server 变成了 node。
产生了一个核心问题:
数据如何同步?比如 Client-A 如何看到 Client-B 发的信息/消息?
对于这个问题,如果 Relay 之间有 P2P 通讯,那么就像区块链节点那样,数据相互同步便解决了。但 Nostr 所描述的做法却是这样的:
- Client-A 给 Relay-A 发数据,Relay-A 存储在本地;
- Client-B 给 Relay-B 发数据,Relay-B 存储在本地;
- 因为要交流,否则就是玩
单机
,于是乎,A 或 B 两个用户,就会:- 在某些中心化社交平台暴露自己的 Relay 的访问 Url;
- 用某其他软件私聊互发 Relay 的 Url;
- 在 3 的基础上,Client-A 或 Client-B 就可以在 Nostr 协议所实现的 Client 端软件上加载到对方 Relay 存储的数据,也就达到了看见/交流的目的。
Relay 的局限性
用户要使用 Nostr 应用,得满足2个条件:
- 下载 Nostr 客户端软件,这个已有,网上可搜;
- 订阅 Relay,这个可以:
- 自己买个服务器,找份 Relay 代码,编译个可执行文件,启动服务,然后连接进来,就可以发数据到它存起来。然后再去找其他朋友的 Relay 链接;
- 去网上找公开的 Relay 链接,订阅进去,从而可以发数据,看数据;
为了解决这些问题,现在 Nostr 衍生出的产品中就有公共 Relay
,比如:snort.social,这些 Relay 提供注册功能
,所谓的注册就是在他的网站生成密钥对
。然后如果用户没钱、没技术自己搭建 Relay 服务器,就可以订阅它的 Relay。
这样的话,所有订阅者都可以在网站页面展示出来,让后来的人看到,等于直接看到其他用户,在这些用户中找好友来进行第一次的谈话。
但是如果没有自己的 Relay 的话,数据其实变相存储在别人的服务器。
技术实现方面,这里要注意一个点:
某一 Client 所有订阅了的 Relay,在 Client 发数据的时候,需要给所有订阅了的 Relay 都发。并非强制,但协议要求。
Nostr 与区块链的关系
Nostr 与区块链关系不大
。
- Nostr 不是某公链的 DApp;
- Nostr 没涉及到智能合约;
- Nostr 没涉及链上请求;
- Nostr 在用户账户部分用了和 BTC 一样的公私钥生成算法;
- Nostr 和其他区块链的 DApp 拥有一样的 web3 概念
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
数据操作部分
- client 和 relay 采用 websocket 的协议传输数据;
- 数据格式是 Json;
- 标准格式是:
[命令,参数,参数...]
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"
}
]
id
就是当前请求的 id,生成方式是对 event 整体内容进行sha256.Sum256
计算得到,这些都可以在文档中得知;pubkey
就是发送者 sender;sig
就是整个 event 的签名,防止内容被篡改,在 Relay 端,会对接收到的 event 验签;kind
直接理解为 event 的类型,告诉 Relay 要达到什么目的;tags
纯粹的标签数组,为了携带辅助参数来实现功能而设立,下面我列举两个场景来说明这个参数的灵活使用,要注意,这个参数要实现什么功能,是没固定说法的,思维在这里要灵活
。
tags 参数使用例子
- 场景:
发布内容引用到其他内容的时候
。可以在 tags 中的 e 标签数组内添加其他 event 的 id; - 场景:
删除自己所发布的 event 的时候
。可以在 tags 的 e 标签中添加想要被删除的 eventId; - 场景:
发私信
。私信的kind 是 4
,此时 content 是加密的,只有接收方能解密,此时 tags 的 p 标签中,就是要接收私信人的 pubkey
Client 发订阅请求
订阅操作的完成可以达成两个目的:
- 只要 websocket 不关闭,只要 Relay 有新的 event 接收到,就会推送到 Client;
- 订阅开始的时候,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 的大同小异,一样具有很强的灵活性。查询的时候所有参数取且操作
。
ids
,指定基于 event 的变化,不指定就是全量订阅;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 是如何进行授权动作的。
- Client 在和 Relay 建立链接的时候,Relay 如果实现了 AUTH 功能,就需要给 Client 返回个
challenge code
; - Client 拿到
challenge code
后得知此 Relay 是要授权接入的,否则一些功能无法使用; - Client 打包 AUTH 请求命令,把发出;
- Relay 验证
challenge code
和其他信息,并存储相关数据;
整个 AUTH 的流程是比较简单的。最核心的信息是 challenge code
,完全可以理解为验证码
,具体怎么去实现这一部分,完全可以由 Relay 自定义
。我上面举的例子是现在 demo 源码的做法,现实中是可以但不限于
下面的拓展:
- 用户在 A 网站购买
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
}
Relay 返回的命令
- 订阅之后,给 Client 推送数据。
["EVENT", <subscription_id>, <event JSON>]
,id 就是订阅时发过来的 id; - 给 Client 的请求报错或提示。
["NOTICE", <message>]
; - 告知 Client 当前 Relay 所有存储的 event 都已经返回。
["EOSE", <subscription_id>]
; - 告知 Client,此 Relay 需要授权,并返回 challenge_code。
["AUTH", <challenge-string>]
- 告知 Client 某事件的结果,成功或失败。
["OK", <event_id>, <true|false>, <message>]
NOTICE
和 OK
的区别是:
- 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 服务
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搜索提供帮助。
我的“区块链”技术书籍:《区块链以太坊DApp开发实战》
、支付宝收款码 https://www.cnblogs.com/linguanh/gallery/825997.html
微信:https://www.cnblogs.com/linguanh/gallery/image/321906.html
银行卡:6217007200076746554 , 林冠宏