搭建一套加密语音通话系统,最容易忽略的不是音频编码,而是信令链路的安全性。WebRTC 浏览器底层已经用 DTLS-SRTP 对 RTP 媒体包做了强制加密,但 offer、answer、ICE 候选这些信令如果走明文 WebSocket,攻击者虽然解不开音频,却能准确知道谁在跟谁通话、什么时间建立连接、双方使用哪些中继地址。Node.js 在 DeCall 方案里承担的角色,正是把这些敏感信令放进 WSS 通道,并配合令牌校验、房间隔离让通话元数据不被随意窥探。下面从整体结构开始,逐步拆解一个可运行的最小实现。

整体架构:三个角色与两层加密
DeCall 的节点分为浏览器终端、Node.js 信令服务器和 NAT 穿透基础设施。浏览器负责采集麦克风、编码音频并通过 RTCPeerConnection 建立点对点连接;Node.js 服务不转发音频流,只负责交换 SDP 和 ICE 候选;STUN 服务器帮助双方发现公网地址,TURN 服务器在对称 NAT 场景下中继加密后的媒体包。这样设计的原因是音频数据量远大于信令数据,如果让 Node.js 转发所有 RTP 包,单机带宽和 CPU 会很快成为瓶颈。
加密分为两个层面。第一层是信令加密,也就是 WebSocket over TLS,保证 offer、answer、房间号、用户 ID 在网络上不可读。第二层是媒体加密,WebRTC 规范要求所有媒体必须通过 DTLS-SRTP 传输,DTLS 握手会在通话双方之间直接完成,生成只有两端知道的 SRTP 密钥。Node.js 只看到加密后的 SDP 指纹,无法解密后续音频,这正是 DeCall 强调的端到端媒体安全性。
下面是一个基础信令服务器的入口代码,它使用 HTTPS 和 Socket.io 在 3000 端口提供服务。实际部署时可以把 Socket.io 挂到已有的 Express 应用上,但证书必须启用,否则浏览器会拒绝在非安全页面调用麦克风。
const https = require('https');
const fs = require('fs');
const express = require('express');
const { Server } = require('socket.io');
const app = express();
const server = https.createServer({
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem')
}, app);
const io = new Server(server, {
cors: {
origin: 'https://your-domain.com',
methods: ['GET', 'POST']
}
});
const rooms = new Map();
io.on('connection', (socket) => {
console.log('socket connected:', socket.id);
});
server.listen(3000, () => {
console.log('DeCall signaling server listening on 3000');
});
信令服务:房间管理、offer/answer 与 ICE 转发
只建立连接还不够,必须把一个通话里的两个用户准确关联起来。DeCall 使用房间模型,每个房间有一个唯一 roomId,加入前先校验令牌,通过后把 socket.id 存入房间成员列表。当一方发送 offer,服务器只把消息转发给同房间的另一方,而不是广播给所有连接,这样可以避免不同通话之间的信令串扰。
ICE 候选的转发比 SDP 更频繁,尤其是在移动网络切换或 NAT 探测阶段。实现时可以单独监听 candidate 事件,把候选对象原样转发。服务器没有必要解析 candidate 字符串,因为候选里包含的是 IP、端口和优先级,解析并不会增加业务价值,反而可能因格式变化造成兼容问题。
下面这段代码补全了房间加入、offer、answer 和 ICE 转发逻辑。为了简化示例,房间成员固定为两人,超过两人的呼叫需要额外设计会议混流或 SFU,但信令路由思路相同。
io.on('connection', (socket) => {
socket.on('join', ({ roomId, token }) => {
if (!verifyToken(roomId, token)) {
socket.emit('error', { message: 'unauthorized' });
return;
}
socket.join(roomId);
const members = rooms.get(roomId) || [];
if (members.length < 2) {
members.push(socket.id);
rooms.set(roomId, members);
socket.to(roomId).emit('peer-joined', { peerId: socket.id });
} else {
socket.emit('room-full', { message: 'room already has two peers' });
}
});
socket.on('offer', ({ roomId, sdp }) => {
socket.to(roomId).emit('offer', { peerId: socket.id, sdp });
});
socket.on('answer', ({ roomId, sdp }) => {
socket.to(roomId).emit('answer', { peerId: socket.id, sdp });
});
socket.on('ice-candidate', ({ roomId, candidate }) => {
socket.to(roomId).emit('ice-candidate', { peerId: socket.id, candidate });
});
socket.on('disconnect', () => {
for (const [roomId, members] of rooms.entries()) {
const index = members.indexOf(socket.id);
if (index !== -1) {
members.splice(index, 1);
socket.to(roomId).emit('peer-left', { peerId: socket.id });
if (members.length === 0) {
rooms.delete(roomId);
}
}
}
});
});
上述代码里的 verifyToken 会在下一节实现。房间成员用数组存储,适合单机双人通话场景。如果希望支持多人或分布式部署,可以换成 Redis 的 Set 结构,并让所有 Node.js 实例共享同一个 Socket.io adapter。
媒体加密:DTLS-SRTP 协商与浏览器端实现
WebRTC 对音频的保护不是通过应用层加密,而是在传输层完成 DTLS 握手。两端创建 RTCPeerConnection 后,浏览器会生成自签名证书,并通过 SDP 交换证书指纹。随后 DTLS 在候选地址上直接握手,双方验证指纹后派生 SRTP 主密钥,用于加密和解密 RTP 音频包。这个过程对 JavaScript 代码完全透明,开发者不需要手动管理密钥,也不应该把任何密钥写入信令。
浏览器侧的通话逻辑主要包括获取麦克风、创建连接、添加媒体轨、生成 offer、应用 answer、监听 ICE 候选。getUserMedia 必须在 HTTPS 或 localhost 下调用,这也是为什么 Node.js 信令服务必须配置 TLS 证书。音频默认使用 Opus 编码,具备较好的抗丢包和低延迟表现,适合语音通话。
客户端示例使用原生 WebSocket 或 Socket.io 客户端均可,这里以 Socket.io 客户端为例。代码中的信令地址需要替换成实际部署的 WSS 地址。
const socket = io('https://your-domain.com', {
auth: { token: clientToken }
});
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
socket.on('offer', async ({ peerId, sdp }) => {
await pc.setRemoteDescription(sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { roomId, sdp: pc.localDescription });
});
socket.on('answer', async ({ peerId, sdp }) => {
await pc.setRemoteDescription(sdp);
});
socket.on('ice-candidate', async ({ candidate }) => {
if (candidate) {
await pc.addIceCandidate(candidate);
}
});
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', { roomId, candidate: event.candidate });
}
};
async function startCall() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
stream.getAudioTracks().forEach((track) => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { roomId, sdp: pc.localDescription });
}
socket.on('peer-joined', () => {
startCall();
});
这段代码中,offer 由后加入的一方发起,前一个用户收到 offer 后生成 answer。双方完成 SDP 交换后,DTLS 握手会在媒体轨道传输前自动进行。如果证书指纹不匹配或握手失败,RTCPeerConnection 会进入 failed 状态,音频不会以明文方式降级传输。
身份认证与防未授权加入:HMAC 令牌
WSS 只能保证信令在网络上不被第三方读取,但不能阻止知道服务地址的人随便创建连接。如果没有应用层鉴权,恶意用户可以遍历 roomId 加入正在进行的通话并获取 SDP,虽然不能直接解密音频,但已经破坏了通话隐私。DeCall 的防御方式是给每个房间签发短期有效的 HMAC 令牌,连接时校验。
Node.js 端利用内置 crypto 模块即可实现,不需要引入 JWT 库。令牌内容可以包含 roomId、用户 ID 和过期时间,服务端用密钥重新计算摘要,只要结果一致且未过期就放行。密钥必须放在环境变量或密钥管理系统中,不能写进代码仓库。
const crypto = require('crypto');
const secret = process.env.DECALL_SECRET || 'change-me-before-production';
function createRoomToken(roomId, userId, ttlSeconds = 120) {
const exp = Date.now() + ttlSeconds * 1000;
const payload = roomId + ':' + userId + ':' + exp;
const digest = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return payload + ':' + digest;
}
function verifyToken(roomId, token) {
const parts = String(token || '').split(':');
if (parts.length !== 4) return false;
const [tokenRoomId, userId, exp, digest] = parts;
if (tokenRoomId !== roomId || Date.now() > Number(exp)) return false;
const payload = tokenRoomId + ':' + userId + ':' + exp;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(expected));
}
客户端在连接前向业务后端申请令牌,然后把它放进 Socket.io 的 auth 字段。令牌过期后连接会被拒绝,通话中掉线重连时需要重新申请。这种方式实现简单,性能开销极低,非常适合中小规模语音服务。
上线前的配置与性能调优
开发环境可以用自签名证书,但生产环境必须使用受信任 CA 签发的证书。浏览器对 getUserMedia 的强制 HTTPS 策略没有例外,使用自签名证书时只能通过手动信任证书的方式测试,真实用户访问会直接失败。证书申请完成后,将 Node.js 服务暴露在 3000 或 443 端口,并在防火墙只放行 HTTPS 和媒体端口。
音质和连通率取决于 STUN/TURN 部署。STUN 能解决大部分家庭 NAT,但企业网络和对称 NAT 环境大概率需要 TURN 中继。可以为 coturn 配置长期凭证,把地址写入 RTCPeerConnection 的 iceServers。TURN 中继的是加密后的 SRTP 包,中继服务器同样无法解密音频内容,但会增加一到两跳延迟,建议按地域就近部署。
从性能角度看,Node.js 信令服务器单核即可支撑数千个并发信令连接,真正的压力不在消息转发,而在房间状态管理和频繁的 ICE 消息。上线前可以用 Socket.io 的负载测试工具模拟大量加入、offer、answer、candidate 和断开连接操作,观察事件循环延迟和内存增长。若单机出现瓶颈,优先把房间状态迁移到 Redis,并用粘性会话保持 Socket.io 连接稳定。
至此,一个具备 WSS 信令加密、HMAC 身份校验、DTLS-SRTP 媒体加密的 DeCall 语音通话系统已经有了完整骨架。根据业务需要,还可以继续叠加录音合规、黑名单、通话质量上报和 AES-GCM 加密的聊天消息,但核心安全边界已经建立:Node.js 不接触音频明文,信令元数据只能在授权成员之间流转。