两个 daemon 跑在不同机器上,中间通过一台便宜 VPS 上的 WebSocket relay 转发消息。relay 是我自己的,但我不想让它能读到内容。一是原则问题,二是这台 VPS 要是被人进了,我不想内存和日志里全是明文。
整个加密层最后写了大概 160 行 TypeScript。Ed25519 做身份签名,X25519 做密钥协商,AES-256-GCM 做对称加密。全部用 node:crypto,零外部依赖。Node 22。
为什么要两套密钥
Ed25519 和 X25519 都是 Curve25519 家族的。Ed25519 用来签名,X25519 用来做 Diffie-Hellman 密钥交换。
数学上它们可以互转。libsodium 有个函数 crypto_sign_ed25519_sk_to_curve25519 专门干这个。我试了,Node.js 的 crypto 模块不暴露这个转换。想用的话要装 tweetnacl 或者 libsodium-wrappers。
我的目标是零外部依赖,所以方案是直接生成两套密钥:
- Ed25519:长期身份密钥,写到磁盘,用来签名和验证身份
- X25519:临时密钥,每次连接生成,连接断开就扔,用来协商对称密钥
身份密钥持久化
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
interface IdentityKeys {
privateKey: crypto.KeyObject;
publicKeyHex: string;
}
async function loadOrCreateKeys(keysDir: string): Promise<IdentityKeys> {
const privPath = `${keysDir}/identity.key`;
const pubPath = `${keysDir}/identity.pub`;
try {
const privDer = await fs.readFile(privPath);
const pubHex = await fs.readFile(pubPath, "utf-8");
const privateKey = crypto.createPrivateKey({
key: privDer,
format: "der",
type: "pkcs8",
});
return { privateKey, publicKeyHex: pubHex.trim() };
} catch {
// 第一次跑,生成新的
}
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
const privDer = privateKey.export({ type: "pkcs8", format: "der" });
await fs.writeFile(privPath, privDer);
await fs.chmod(privPath, 0o600);
const pubSpki = publicKey.export({ type: "spki", format: "der" });
// 44 字节回来,前 12 字节是 ASN.1 头,真正的公钥是 12-43
const publicKeyHex = pubSpki.subarray(12).toString("hex");
await fs.writeFile(pubPath, publicKeyHex);
return { privateKey, publicKeyHex };
}
SPKI 导出那个坑花了我不少时间。你让 Node 导出 Ed25519 公钥,它给你 44 字节而不是 32 字节。我拿 xxd 一个字节一个字节对着 RFC 8032 的测试向量比,比了好一会儿才搞明白:前 12 字节是 ASN.1 的封装头,Node 默认就带上了。
subarray(12) 切掉前缀,拿到干净的 32 字节公钥。丑是丑了点。
签名部分:
function sign(privateKey: crypto.KeyObject, payload: unknown): string {
const message = Buffer.from(JSON.stringify(payload), "utf-8");
const signature = crypto.sign(null, message, privateKey);
return signature.toString("base64");
}
注意 crypto.sign 第一个参数是摘要算法。Ed25519 必须传 null,因为哈希是算法内置的。我第一次传了 "sha256",直接报错,错误信息完全看不出是这个原因。
X25519 密钥协商
每次连接时双方各生成一个临时 X25519 密钥对:
interface KeyPair {
publicKey: Buffer;
privateKey: Buffer;
}
function generateKeyPair(): KeyPair {
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
return {
publicKey: publicKey.export({ type: "spki", format: "der" }),
privateKey: privateKey.export({ type: "pkcs8", format: "der" }),
};
}
拿到对方的公钥后做 ECDH,再用 HKDF 派生对称密钥:
function deriveSessionKey(
localPrivateKey: Buffer,
remotePubKey: Buffer,
): Buffer {
const privKey = crypto.createPrivateKey({
key: localPrivateKey,
format: "der",
type: "pkcs8",
});
const pubKey = crypto.createPublicKey({
key: remotePubKey,
format: "der",
type: "spki",
});
const sharedSecret = crypto.diffieHellman({
privateKey: privKey,
publicKey: pubKey,
});
return Buffer.from(
crypto.hkdfSync("sha256", sharedSecret, "", "relay-e2e-v1", 32),
);
}
HKDF 这步不能省。ECDH 出来的原始值是椭圆曲线上的一个点,不是均匀分布的随机字节。info 参数 "relay-e2e-v1" 绑定到这个协议,换个协议换个字符串就行。
AES-256-GCM 加解密
function encrypt(sessionKey: Buffer, plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", sessionKey, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// iv(12) + authTag(16) + 密文
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
function decrypt(sessionKey: Buffer, encoded: string): string {
const data = Buffer.from(encoded, "base64");
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", sessionKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}
IV + auth tag + 密文打包成一个 base64 字符串。接收方知道布局,直接按偏移量切。
一个浪费了一整晚的 bug
Error: Unsupported state or unable to authenticate data
decipher.final() 抛这个错说明 auth tag 校验失败。我当时确信是 ECDH 算错了,两边加了 console.log 把 shared secret 导出来对比,一模一样。盯着代码看了很久。
最后发现:发送端用的 .toString("base64"),接收端用的 .toString("base64url")。
base64 和 base64url 的区别就是 +/ 换成了 -_,输出内容会不一样。解码出来的 Buffer 不同,auth tag 自然对不上。错误信息不会告诉你"是编码格式不对",它只说"认证失败"。
通过 relay 交换密钥
relay 就是个 WebSocket 服务器,两个 peer 加入同一个 room。连上之后双方先发自己的 X25519 公钥,这条消息不加密:
// 双方连接后都发这个
ws.send(JSON.stringify({
type: "DATA",
room_id: roomId,
payload: JSON.stringify({
_type: "KEY_EXCHANGE",
pubkey: myKeyPair.publicKey.toString("base64"),
}),
}));
接收端:
function handleData(roomId: string, payload: string): void {
const room = rooms.get(roomId);
if (!room) return;
try {
const parsed = JSON.parse(payload);
if (parsed._type === "KEY_EXCHANGE" && parsed.pubkey) {
const remotePub = Buffer.from(parsed.pubkey, "base64");
room.sessionKey = deriveSessionKey(myKeyPair.privateKey, remotePub);
return;
}
} catch {
// JSON.parse 失败 = 这是加密后的消息
}
if (room.sessionKey) {
try {
const plaintext = decrypt(room.sessionKey, payload);
// 处理解密后的消息
} catch {
console.error("decrypt failed for room", roomId);
}
}
}
用 JSON.parse 成不成功来区分加密/未加密消息。不好看,我知道。但密钥交换复用了 relay 已有的 DATA 消息类型,不需要改 relay 代码。
DER vs PEM
我存密钥用的 DER 格式。比 PEM 小,没有 base64 开销,没有 -----BEGIN 头。
但如果你把 DER 字节传给 Node,然后 format 写成了 "pem":
error:0480006C:PEM routines::no start line
我搜了这个错误,搜出来一堆 OpenSSL 论坛讨论证书链的帖子。花了二十分钟才意识到就是 format 字符串写错了:
// 对的
crypto.createPrivateKey({ key: derBuffer, format: "der", type: "pkcs8" });
// 错的——DER 内容但告诉 Node 按 PEM 解析
crypto.createPrivateKey({ key: derBuffer, format: "pem", type: "pkcs8" });
这个方案不做什么
坦白几个安全局限。
没有前向保密(forward secrecy 只到 session 级别)。如果有人录了流量,又拿到了 X25519 私钥,能解密那个 session 的所有消息。真正的前向保密需要像 Signal 那样做 key ratcheting。我的场景里临时密钥只存在内存里,连接断开就没了,够用了。
relay 可以中间人攻击。它能看到两边的 X25519 公钥经过,理论上可以替换掉然后坐在中间。解决办法是用 Ed25519 签名密钥交换消息。还没做,因为 relay 是我自己的机器。我知道这是在偷懒。
没用 GCM 的 AAD(附加认证数据)。把一个 room 里的加密消息重放到另一个 room,技术上能解密成功。在两端都是自己控制的场景下优先级很低。
全部基于 node:crypto。Node 22 上这套 API 比以前好用多了,KeyObject 的导入导出不用再跟各种格式转换搏斗。160 行 TypeScript,零依赖,解决了"relay 看不到消息内容"这个核心问题。