签署每一条 WebSocket 消息

TLS 证明通道在传输过程中未被篡改。但它并不能证明谁产生了消息,并且一旦消息被记录、持久化或重放,它就不再有效。本教程结合 sgcWebSockets 和 sgcSign,让您的服务器发出的每个帧——或客户端发送的每个帧——都携带一个分离式 CAdES 签名,对方将根据已知的签署者证书进行验证。

身份绑定的消息
每帧分离式 CAdES
可抵御重放和日志记录

TLS 还不够

TLS 保护两个端点之间传输中的字节。一旦帧被记录、归档或通过中间件重放,TLS 证据就消失了——只有在负载本身上的数字签名才能保留下来。

逐消息不可否认性

交易大厅、博彩交易所、IoT 命令通道和临床数据流都需要每条消息的防篡改审计跟踪。每个帧上的分离式 CAdES 签名让接收者可以在数小时或数年后证明谁究竟在何时发出了什么。

双向身份

WebSocket mTLS 在连接时证明客户端身份。逐消息签名按帧证明应用身份——当 WebSocket 网关是共享边缘代理而真正的生产者在下游时很有用。

重放安全

在已签名的负载中包含 WebSocket 会话 ID 和单调递增的 nonce。重放攻击者在不访问私钥的情况下无法使用新的 nonce 重新签署消息。

信封形状

将每个 WebSocket 消息包装在一个小型 JSON 信封中,承载负载、nonce、时间戳和 base64 编码的分离式签名。

信封约定

信封基于确定性字节序列签名:session_id || nonce || timestamp || payload_sha256。我们使用 SHA-256,因为负载可能是二进制的(Protobuf、MessagePack),并且我们希望签名器的输入是固定长度。

CAdES 是这里的正确选择——它生成紧凑的 CMS/PKCS#7 签名,base64 编码后足够小,可以与负载一起放下。PAdES 特定于 PDF,XAdES 特定于 XML;两者都不适合二进制或 JSON 实时通道。

envelope.json
{
  "sid": "7f3a-b6e1",
  "nonce": 42,
  "ts": "2026-05-26T11:24:09Z",
  "payload": { /* the actual app message */ },
  "sig": "MIIK...base64...detached CAdES..."
}

引入单元并加载签名密钥

sgcWebSockets 提供传输,sgcSign 提供签名引擎。它们不共享任何状态——您需要在应用代码中将它们连接起来。

Uses 子句

对于客户端和服务器,您都需要 WebSocket 组件、CAdES 配置文件、密钥提供程序和验证器。客户端使用 PFX,服务器端使用长期服务证书(通常来自 Windows 存储)。

uses-clauses.pas
uses
  Classes, SysUtils,
  // sgcWebSockets
  sgcWebSocket, sgcWebSocket_Classes,
  // sgcSign
  sgcSign_KeyProvider_PFX,
  sgcSign_CAdES,
  sgcSign_Profile_CAdES,
  sgcSign_Verifier,
  sgcJSON;

客户端——发送前签名

挂接客户端的出站流,计算负载加会话元数据的摘要,附加 CAdES 签名,并通过 WriteData 发出信封。

包装负载

将签名器和密钥提供程序保留为表单或数据模块上的字段。TsgcSignCAdES.SignDetached 接受字节缓冲区并返回分离的 CMS 结构。我们在 BuildToBeSigned 中构建规范字节序列——会话 ID、nonce、ISO 8601 时间戳和负载字节的 SHA-256。

TsgcWebSocketClient 上调用 WriteData 会将信封作为常规文本帧发送。服务器端验证器会在将负载传递给应用处理程序之前反转此过程。

client-send.pas
procedure TForm1.SendSignedMessage(const aPayload: string);
var
  vTBS: TBytes;
  vSig: TBytes;
  vEnvelope: ISuperObject;
begin
  Inc(FNonce);
  vTBS := BuildToBeSigned(FSessionId, FNonce, NowUTC, aPayload);

  vSig := FCAdESSigner.SignDetached(vTBS);  // CMS SignedData, bytes

  vEnvelope := SO();
  vEnvelope.S['sid']     := FSessionId;
  vEnvelope.I['nonce']   := FNonce;
  vEnvelope.S['ts']      := DateToISO8601(NowUTC, True);
  vEnvelope.S['payload'] := aPayload;
  vEnvelope.S['sig']     := TNetEncoding.Base64.EncodeBytesToString(vSig);

  WSClient.WriteData(vEnvelope.AsJSon);
end;

服务器端——分发前验证

挂接服务器的 OnMessage 事件,根据信封字段重新计算待签名字节,验证分离式签名,然后才将负载转发给您的业务逻辑。

OnMessage 处理程序

处理程序解析信封,重建规范字节序列,然后使用签名字节和待签名字节调用 TsgcSignatureVerifier.VerifyDetached。验证器返回带有签署者主题、链状态和任何时间戳元数据的报告。

在服务器内按指纹缓存受信任的签署者证书——在高吞吐量连接上对每个帧进行完整链验证会变得昂贵。在首次验证后,您只需确认签名有效且指纹是您已经信任的指纹之一。

server-receive.pas
procedure TFormSrv.WSServerMessage(Connection: TsgcWSConnection;
  const Text: string);
var
  vEnv: ISuperObject;
  vSig, vTBS: TBytes;
  vReport: TsgcSignatureReport;
begin
  vEnv := SO(Text);
  vSig := TNetEncoding.Base64.DecodeStringToBytes(vEnv.S['sig']);
  vTBS := BuildToBeSigned(
    vEnv.S['sid'], vEnv.I['nonce'],
    ISO8601ToDate(vEnv.S['ts'], True),
    vEnv.S['payload']);

  vReport := FVerifier.VerifyDetached(vSig, vTBS);
  if vReport.Signatures[0].Status <> svValid then
  begin
    Connection.Disconnect;
    Exit;
  end;

  HandlePayload(Connection, vEnv.S['payload']);
end;

重放保护

有效签名还不够——捕获已签名信封的攻击者只要证书仍然有效就可以重放它。将每条消息绑定到会话并强制执行单调 nonce。

每连接 nonce 窗口

保留一个由 (SessionId, SignerThumbprint) 作为键的服务器端字典,记录迄今为止看到的最高 nonce。拒绝任何 nonce 不严格大于该值的信封。拒绝任何时间戳与服务器时钟相差超过几秒的信封——即使相同的 nonce 不知何故被重用,也要限制重放窗口。

对于无法保证顺序的有损网络,将严格大于检查切换为滑动窗口:接受最近 N 个未曾见过的条目内的任何 nonce,拒绝任何更旧的。基本要求是没有两个帧携带相同的 (SessionId, Nonce)

replay.pas
function TFormSrv.AcceptNonce(const aSid: string;
  aNonce: Int64): Boolean;
var
  vLast: Int64;
begin
  FNonceLock.Acquire;
  try
    vLast := FNonces.Items[aSid];
    Result := aNonce > vLast;
    if Result then
      FNonces.AddOrSetValue(aSid, aNonce);
  finally
    FNonceLock.Release;
  end;
end;

通常会出什么问题

开发者首次将 sgcSign 接入 sgcWebSockets 时最常见的三种失败模式。

规范化不一致

如果客户端从具有插入顺序键的 JSON 字符串构建待签名字节,而服务器从解析后重新序列化的对象重建它们,摘要将不匹配。始终签名您自己构造的确定性字节序列——不要签名重新序列化的 JSON。

高吞吐量流上的延迟

使用 RSA-2048 密钥签署每条消息会为每帧增加几毫秒。在 10k msg/s 的流上,这就变成了瓶颈。切换到 ECDSA-P256 密钥(快一个数量级)或通过将 N 条消息分组到单个信封中进行批量签名。

忘记 SubjectKeyIdentifier

CAdES 默认包含签署者证书,这会使每帧膨胀 1-2 KB。在会话的第一帧之后使用 vProfile.IncludeCertificate := False,让验证器根据其受信任的签署者缓存按 SubjectKeyIdentifier 进行匹配。

接下来可以去哪里

端到端加密是对签名的补充。sgcSign 服务器为客户端机群集中化密钥保管。

10 种密钥提供程序

使用 Azure Trusted Signing、AWS KMS、Google KMS 或 HashiCorp Vault,完全将私钥保留在应用程序进程之外。

阅读更多 →

sgcSign 服务器

REST 签名服务,让客户端永远看不到私钥。当 WebSocket 生产者是沙盒化工作进程时非常有用。

阅读更多 →

sgcWebSockets

本教程使用的完整 WebSocket / HTTP2 / HTTP3 / MQTT / AMQP 传输库。

阅读更多 →

准备好签署每一个 WebSocket 帧了吗?

两个库都在同一个试用版中发布。下载一次,在一个下午内构建端到端签名通道。