如何创建自己的去中心化信使协议
How to Create Your Own Decentralized Messenger Protocol
文章以 SMTP 的联邦机制为参照,讲解如何从零设计一个去中心化信使协议 lnchat:通过 `/.well-known/lnchat.json` 完成服务器发现,用 resolve_users 端点交换用户公钥以支持端到端加密,并要求验证联邦请求确实由控制源域名的服务器签名。协议暂不支持附件、多设备、群组和已读回执,仅保留单设备的纯文本消息交换。
假设你想创建一个系统,让每个服务器都能“加入”一个网络,并开始与该网络中的其他成员交换一些东西。你会如何着手这样的任务? 你可以创建一个小型的集中式编排器,它会接受加入请求并为接收方处理这些请求,但这不是去中心化的。如果我们想要一个真正“即插即用”且没有任何集中式服务器的解决方案呢? 其中一种解决方案就是联邦。

什么是联邦
一个很好的例子是 SMTP(用于发送电子邮件的协议)。它基本上是去中心化的。任何人都可以运行自己的邮件服务器。不存在一个单一的中央权威说“是的!这台服务器可以有电子邮件,但那一台?不行!”。 每台服务器都使用一种众所周知的协议进行通信,并在服务器之间交换信息。 你甚至可以把它称为“具有已知负载模式的 API”,尽管这有点过于简化了。
我们就以电子邮件为例,从显而易见的地方开始。你有没有想过为什么电子邮件地址包含一个 @<domain> 部分?为什么不能只是 my_nick?
必须知道哪个域名对该身份负责!对吧!
该域名在整个联邦交换中扮演着非常重要的角色。
这是因为如果你从你的电子邮件,比如说 [email protected],写信给 [email protected],发送邮件服务器会取用那个 y.com 部分,进行 MX 记录的 DNS 发现,连接到负责该域名的邮件服务器,并开始交换消息。
这就是联邦的核心思想:服务器在经历某种“发现过程”后直接相互通信。在我们的小型电子邮件示例中,发现部分就是对 MX 记录的 DNS 查询。
联邦信使协议
好的,但我们来这里是为了创建我们自己的信使协议,所以让我们开始吧。
首先,我们需要像优秀的系统(在本例中是协议)设计者那样收集一些需求。
- 让我们假设我们想要端到端加密——因此服务器应该只能看到加密的消息数据,而终端客户端将处理加密和解密。
- 我们也不想盲目信任任何发送 HTTP 请求并说“你好,我是 x.com”的人。因此我们需要一种方法来验证联邦请求是由控制源域名的服务器签名的。
- 另外,我们不想让它变得超级复杂,所以让我们放弃任何花哨的东西,比如附件、多设备解决方案、群组、已读回执等。目前,我们想要一个去中心化的、简单的、仅限单一设备的文本交换。欢迎来到 90 年代!:D
这应该是我们需求的基本最低限度。那么我们究竟该如何开始做呢?
让我们从最简单的开始,然后再转向更复杂的东西。
用户身份
我们还要假设每个服务器都已经有一些用户,具备注册、登录等功能。这样我们就不用花一百万年来从头做所有事情。
我们需要强制用户身份像电子邮件一样。
所以是 <nickname>@<domain>,否则我们的联邦将不知道要呼叫谁。
这里重要的一点是 [email protected] 不是电子邮件地址。它只是使用了相同的形状,因为这个形状很有用。域名告诉其他服务器在哪里发现联邦元数据。
我们可以采用,例如,x.com > adam、x.com#adam 或 adam on x.com。这由你决定。
发现
现在是第一个真正的工作。发现是类似于前面提到的 MX 记录查找过程的部分。
我们完全自由地决定如何处理这件事,所以让我们用服务器上的一个简单文件来实现。
为此,我们可以使用典型的 .well-known 目录。
在我们的例子中,我们可以把协议称为 lnchat(来自 LearN chat)。
这将是我们对协议的第一个要求。
服务器必须在以下位置提供文件:
GET /.well-known/lnchat.json
在里面我们可以放入版本号以及稍后在文章中构建的其他重要信息。目前,我们只放版本号即可:
{
"protocol": "lnchat.federation.v1"
}
顺便说一句,始终为协议添加版本号是个好习惯。它能强化契约,并为你省去很多麻烦。
这样,服务器就可以检查我们想要连接的服务器是否支持我们的协议。如果支持,它们就可以从这个 .well-known 文件中获取所有必要信息。
端到端
由于我们想要端到端加密,我们需要客户端加密密钥。
通常这意味着一个留在客户端的私钥,以及一个用户服务器可以与其他服务器共享的公钥。
- 公钥由发送方用作该用户加密的输入。
- 私钥由接收方用于解密。
私钥绝不能以明文形式发送到服务器。如果服务器能读取私钥,那么服务器就能读取消息,我们就不再拥有真正的端到端加密了。
重要:如果服务器负责发布用户的公钥,恶意或被攻陷的服务器可能会在密钥上撒谎。真正的端到端加密需要某种方式来察觉这一点,例如联系人密钥警告、指纹、TOFU 或密钥透明度。
所以我们需要一个用于共享用户公钥的端点。每个服务器都应将其作为我们协议的一部分来实现。那么让我们来构建它吧!
首先,让我们在发现文件中添加关于在哪里查找该端点的信息。这使每个服务器都能选择自己的 URL。
最好不要在这里强制使用严格路径,因为它可能与服务器上已有的路由冲突。
{
"protocol": "lnchat.federation.v1",
"endpoints": {
"resolve_users": "https://example.com/federation/v1/users/resolve"
}
}
但 resolve_users URL 在每个服务器上可能是不同的 URL。
解析用户
resolve_users 端点将负责返回该服务器上用户的当前公共加密密钥。
其他服务器会在发送消息前调用它,以便知道哪个密钥应用于哪个接收者。
我们可以为这个端点设计如下模式:
{
"protocol": "lnchat.federation.v1",
"users": ["[email protected]", "[email protected]"]
}
为什么是用户数组而不是单个用户?让我们想象一下它将被用于高流量服务器。我们应该提供一种方式来缓解性能压力。 如果服务器可以在一个请求中解析多个用户,性能会好得多——HTTP 调用更少。
对于响应,我们这样做:
{
"protocol": "lnchat.federation.v1",
"status": "ok",
"users": [
{
"user": "[email protected]",
"status": "ok",
"key": {
"key_id": "adam-enc-2026-07-a",
"public_key": "base64url-raw-32-byte-x25519-public-key"
}
},
{
"user": "[email protected]",
"status": "not_deliverable"
}
]
}
因此用户可以使用 key_id 和 public_key 来公开密钥。
为什么我们需要 key_id?
嗯,想象一下流程会是什么样。
客户端获取用户的公钥,可能将其存储在某些缓存中,然后使用该密钥发送消息。
如果该密钥发生变化怎么办?接收方将永远无法解密。我们需要一种方法来区分不同的密钥,甚至显示密钥已更改的警告!也许它已被泄露!
我还添加了两个状态:ok 和 not_deliverable。ok 不言自明,但我添加了 not_deliverable 用于我们还没有该用户公钥的情况。
请记住,用户需要生成这些密钥。如果我们想要真正的端到端加密,就不能安全地在后端完成。
因此有可能我们已经有一些从未生成过密钥的用户。我们应该明确说明该用户不是接收加密消息的有效用户。
发布密钥
目前,我们只处理了用于服务器到服务器密钥交换的端点。但那个公钥最初将如何出现呢?
用户可以使用 Web Crypto API 生成密钥,将私钥存储在本地,并将公钥发送到后端保存在数据库中。
const keyPair = await window.crypto.subtle.generateKey(
{ name: "X25519" },
false,
["deriveBits"]
);
const publicKey = await window.crypto.subtle.exportKey(
"raw",
keyPair.publicKey
);
一般来说,除非确实需要,否则应避免将私钥导出到客户端之外。浏览器可以将 CryptoKey 对象存储在 IndexedDB 中。
如果私钥是不可导出的,就不太可能意外地将其作为字符串泄露到某处。
这个密钥并非字面意义上直接用于加密文本。它由加密方法使用,在发送方和接收方之间创建共享密钥。
那么从用户的角度来看,整个流程会是什么样子?
sequenceDiagram participant C as 客户端 participant IDB as IndexedDB participant HS as 主服务器 participant ES as 他人的服务器 C->>C: 生成加密密钥 C->>IDB: 存储私钥 C->>HS: 存储 public_key ES->>HS: 获取 .well-known/lnchat.json ES->>HS: 调用 resolve_users HS->>ES: user_public_key
这里我们假设我们的主服务器可以存储 public_key,并且它会根据我们的协议 schema 通过 resolve_users 端点暴露出来。
消息传递
现在我们可以解析收件人的公钥了。很好。但我们仍然需要实际发送一些东西。
对于第一个版本,让我们保持职责划分简单:
- 客户端加密消息正文。
- 主服务器验证请求,创建联邦信封,并将其发送到收件人服务器。
- 收件人服务器收到消息,对其进行验证,并将其存储起来,直到用户请求它。
- 用户使用其私钥解密消息。
sequenceDiagram participant SC as 发送方客户端 participant SS as 发送方服务器 participant RS as 收件人服务器 participant RC as 收件人客户端 SC->>SS: 请求收件人公钥 SS->>RS: resolve_users RS-->>SS: 公钥 + key_id SS-->>SC: 公钥 + key_id SC->>SC: 加密消息正文 SC->>SS: 加密消息 SS->>RS: 联邦消息 RS->>RC: 加密消息 RC->>RC: 解密消息正文
首先,客户端在本地加密消息。 然后客户端可以向自己的服务器发送类似这样的内容:
{
"recipient": "[email protected]",
"recipient_key_id": "barney-enc-2026-07-a",
"encapsulated_key": "base64url-hpke-enc",
"ciphertext": "base64url-ciphertext"
}
我们稍后会讨论
encapsulated_key和ciphertext。所以现在我们先接受它是一个“秘密消息”。
而密文中的明文可以非常小:
{
"protocol": "lnchat.message.v1",
"type": "text",
"body": "hello"
}
或者实际消息可以是简单文本,但最好将其打包成某种格式,这样我们就可以在消息本身之外发送更多元数据。也许以后这可以用于简单版本的附件等。
然后服务器创建自己的 HTTP 请求并将其发送到 Recipient Server。但是发送到哪个端点呢?
嗯,为了实现这一点,我们需要在发现文件中再添加一个端点。
{
...
"endpoints": {
...
"send_messages": "https://example.com/federation/v1/message"
}
}
现在 Sender Server 可以接收加密数据并将其发送到 Recipient Server。
载荷可以像这样:
{
"protocol": "lnchat.federation.v1",
"message_id": "019f47a9-b138-79d2-8ec3-38a2a9326955",
"sender": "[email protected]",
"recipient": "[email protected]",
"recipient_key_id": "barney-enc-2026-07-a",
"encapsulated_key": "base64url-hpke-enc",
"ciphertext": "base64url-ciphertext"
}
注意我们添加了 message_id 和 sender,但它们在客户端消息中并不存在。这是有意为之。我们不应该信任客户端提供真实的 sender id。用户已经通过身份验证,因此我们始终可以在那里填入他们的名字。
而 message_id 是为了实现幂等性。如果服务器两次发送同一条消息,而第一条已经处理过,我们应该拒绝第二条。
message_id 应该是一个随机生成的字符串。例如,我们可以使用 UUIDv7。
recipient_key_id 应该与我们从 resolve_users 端点获取的 id 相同。它告诉收件人使用哪个密钥来加密消息。
那么如何加密载荷呢?
你可能会问,encapsulated_key 是怎么回事?
嗯,对于现代消息系统来说,使用一种叫做 HPKE 的东西是很好的。
HPKE 本身解释起来有点太复杂,不适合在本文中完整展开,所以我们用一个更简单的思维模型。 直接用非对称密钥加密在计算上可能很重。所以思路是:
- 我们生成一个新的对称密钥。这种密钥用同一个密钥来加密和解密消息。
- 我们用刚生成的对称密钥加密消息。
- 我们用接收方的公钥封装这个对称密钥,并把封装后的密钥随消息一起发送。
- 接收方可以恢复出对称密钥,然后用它来解密消息。
这并不是精确的 HPKE 规范,但已经足够接近,可以理解为什么载荷有两部分:封装的密钥材料和密文。
那么在 JS 里该怎么做呢?最好不要再自己用原始加密方法来实现。我们把它交给 GitHub 上更聪明的人,像这样:https://github.com/dajiaji/hpke-js
为了便于阅读,我们假设 recipientPublicKey 已经从解析出的 base64url 公钥中导入。
import {
Aes256Gcm,
CipherSuite,
HkdfSha256,
} from "@hpke/core";
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
const suite = new CipherSuite({
kem: new DhkemX25519HkdfSha256(),
kdf: new HkdfSha256(),
aead: new Aes256Gcm(),
});
function b64url(bytes) {
return new Uint8Array(bytes).toBase64({
alphabet: "base64url",
omitPadding: true,
});
}
const plaintext = new TextEncoder().encode(JSON.stringify({
protocol: "lnchat.message.v1",
type: "text",
body: "hello",
}));
const sender = await suite.createSenderContext({
recipientPublicKey,
});
const ciphertext = await sender.seal(plaintext);
const payload = {
recipient: "[email protected]",
recipient_key_id: "barney-enc-2026-07-a",
encapsulated_key: b64url(sender.enc),
ciphertext: b64url(ciphertext),
};
对服务器消息进行签名
这样我们就能发现服务器、解析用户、获取用户密钥并发送加密消息。不错。 但我们还有一个大问题。
y.com 怎么知道消息确实来自 x.com?
任何人都可以发送 HTTP 请求,并在 JSON 里放入这些:
{
"sender": "[email protected]"
}
这证明不了什么。所以联邦请求需要由发送方服务器签名。

我们需要一个单独的密钥对。不要把它和用户加密密钥混在一起。
- 用户加密密钥用于消息内容。
- 服务器签名密钥用于证明联邦请求来自某个特定服务器。
所以我们再往发现文件里加一个字段。这次是服务器自己的公钥。
{
"protocol": "lnchat.federation.v1",
"server_keys": [
{
"key_id": "2026-07-ed25519-a",
"public_key": "base64url-ed25519-spki-der-public-key"
}
],
...
}
要生成密钥,你大概可以使用任何现代加密库。例如:
import { generateKeyPairSync } from "node:crypto";
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
});
const publicKeyDer = publicKey.export({
type: "spki",
format: "der",
});
storage.savePrivateKey(privateKey);
discovery.storePublicKey(publicKeyDer.toString("base64url"));
记得把私钥保存在服务器上的安全位置,并在发现文件中发布公钥。
现在当 x.com 向 y.com 发送请求时,它还必须发送几个请求头:
LNChat-Source-Domain: x.com
LNChat-Target-Domain: y.com
LNChat-Key-Id: 2026-07-ed25519-a
LNChat-Timestamp: 2026-07-09T12:00:00Z
LNChat-Request-Id: 6fb10f56-7b5f-4f68-9b17-6c07cdb3c4d8
LNChat-Body-SHA256: base64url-sha256-of-body
LNChat-Signature: base64url-ed25519-signature
思路是:
x.com创建请求体。x.com对请求体做哈希。x.com用它的私有服务器密钥对请求的严格文本表示进行签名。y.com从x.com的发现文件下载服务器公钥。y.com验证签名。
这里有一个无聊的细节非常重要:两台服务器必须构建完全相同的字符串。
相同的换行。相同的路径。相同的时间戳格式。相同的哈希。
如果一台服务器签名的字符串与另一台服务器验证的字符串略有不同,验证就会失败。
那个严格的文本表示可以像这样:
LNCHAT-FEDERATION-V1
POST
/federation/v1/message
x.com
y.com
2026-07-09T12:00:00Z
6fb10f56-7b5f-4f68-9b17-6c07cdb3c4d8
base64url-sha256-of-body
为什么我们还要发送 version、method 和 URL?嗯,send_messages 端点应该强制要求签名。但为什么不要求所有联邦端点都要求这种签名呢?建立这样一个概念是很好的做法:到达你服务器的所有内容都可以被验证为来自它声称的来源服务器。
顺便注意一下,我们签名的是请求体的哈希,而不是完整请求体,对吧?
base64url-sha256-of-body
嗯,这样只对一小段文本签名而不是 20MB,会更快。记住,这是签名,不是加密。
签名证明发送方拥有私有服务器密钥,并且恰好对这份规范请求签了名。时间戳和请求 ID 也很重要,因为它们能帮助接收方拒绝过期或重放的请求。
所以在发送方服务器上你签名:
import { sign, verify } from "node:crypto";
const canonicalRequest = [
"LNCHAT-FEDERATION-V1",
"POST",
"/federation/v1/message",
"x.com",
"y.com",
"2026-07-09T12:00:00Z",
"6fb10f56-7b5f-4f68-9b17-6c07cdb3c4d8",
"base64url-sha256-of-body",
].join("\n");
const signature = sign(
null,
Buffer.from(canonicalRequest, "utf8"),
privateKey
);
console.log(signature.toString("base64url"));
而在接收方服务器上你必须验证。但为了做到这一点,你首先需要:
- 检查
LNChat-Source-Domain请求头以确定调用者是谁 - 请求
.well-known/lnchat.json文件,并从LNChat-Source-Domain的域名获取公钥 - 从 base64url 解码公钥和签名
- 构建
canonicalRequest文本 - 使用发现文件中的公钥和
LNChat-Signature头部中的签名验证该文本
安全提示:避免 SSRF(服务端请求伪造),即远程服务器诱骗你的服务器调用内部或私有网络地址。获取
.well-known/lnchat.json时,请验证来源域名,要求使用具有有效证书的 HTTPS,并且不要跟随指向私有或本地网络地址的重定向。
import { createPublicKey, verify } from "node:crypto";
const publicKey = createPublicKey({
key: Buffer.from(discoveryPublicKey, "base64url"),
type: "spki",
format: "der",
});
const signature = Buffer.from(signatureHeader, "base64url");
const ok = verify(
null,
Buffer.from(canonicalRequest, "utf8"),
publicKey,
signature
);
console.log(ok);
如果 ok 为 true,我们就知道此请求是由持有与发现公钥匹配的私钥的人签名的。
这仍然不能证明用户本人亲自输入了消息。
它证明服务器 x.com 接受了消息并签署了联邦请求。
但在这个简单版本中,我们已经信任自己的主服务器来处理登录、发送者身份和路由。所以目前这就足够了。
sequenceDiagram participant HS as 主服务器 participant ES as 他人的服务器 HS->>HS: 保留服务器签名私钥 HS->>ES: 发送已签名的联邦请求 ES->>HS: 获取发现信息 HS-->>ES: 公开签名公钥 ES->>ES: 使用公钥验证签名 ES->>ES: 若有效则处理请求
这就是联邦的基本形态:
- 身份使用域名。
- 域名暴露发现元数据。
- 服务器使用发现信息来查找端点和公开服务器密钥。
- 客户端使用收件人公钥加密消息。
- 服务器使用签名密钥来认证联邦请求。
来源:Hacker News · karboosx.net