不用 libsodium,纯 Node.js crypto 实现端到端加密(~160 行 TypeScript)

4 阅读6分钟

两个 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 看不到消息内容"这个核心问题。