E2EE 身份验证:阻止中间人的密钥调包 | eSeGeCe 博客

E2EE 身份验证:阻止中间人的密钥调包

· 组件

当我们推出 sgcWebSockets 中的端到端加密时,保证是清晰的:Alice 和 Bob 用 ECDH 派生出共享密钥,用 AES-GCM 加密,而转发流量的服务器只能看到密文。这一部分是牢靠的。但在这一切之前还有一步,是加密本身无法保护的,而真正的攻击恰恰就发生在这一步:Alice 必须从某个地方拿到 Bob 的公钥,而她是通过服务器拿到的。

把这个攻击具体想象一下。Alice 向服务器索要 Bob 的公钥。一台被攻陷的服务器、一个恶意中继,或者任何接管了那一跳的人,都不会转发 Bob 的密钥。它会生成一对自己的密钥,把自己的公钥交给 Alice,并标注为"Bob"。然后它在另一个方向做同样的事,把另一个自己的密钥交给 Bob,标注为"Alice"。Alice 现在完美地加密了,加密给了攻击者。攻击者解密、阅读、可能改写,再用 Bob 真正的密钥重新加密并转发。Bob 完美地解密了,解自攻击者。双方都看到一把绿色的小锁。双方都错了。这就是经典的中间人密钥调包,再多的 AES-GCM 也解决不了,因为这段对话的两半都是货真价实、正确无误地加密着的。它们只是加密给了错误的人。

2026.7 版本起,sgcWebSockets 堵上了这个缺口。每个端点都可以持有一个长期身份密钥,用它为自己的临时加密密钥签名,而对端会在派生任何共享密钥之前先验证这个签名。你的应用程序决定如何处理对端的指纹:首次使用时接受并固定它、在带外比对它,或者拒绝任何完全没有签名的密钥。它默认关闭,在你打开它之前,线上格式保持不变。

加密不等于认证

这是两个不同的属性,值得直白地说清楚你拥有的到底是哪一个。

加密回答的是"第三方能读到这个吗?"。ECDH 加上 AES-GCM 回答了它:不能。认证回答的是"我加密时用的那把密钥,真的属于我以为在与之交谈的那个人吗?"。单纯的 ECDH 完全没有回答这个问题。谁发来密钥,它就乐呵呵地和谁协商出一个共享密钥,至于对方是谁,它没有任何看法。

本文讨论的全都是第二个问题。这里的任何内容都不会改变密码套件、密钥派生或消息格式。它增加的是对临时密钥的一个签名,以及一个让你的应用程序对密钥背后那个人说"是"或"否"的地方。

长期身份密钥

现在一个 E2EE 会话里有两种密钥,把它们分清楚就是整个思路的关键。

临时加密密钥就是客户端原本就在使用的那对 ECDH 密钥。它只存在于会话期间,共享密钥就是从它派生出来的,而且它可以随意重新生成。它并不能说明你是谁。

身份密钥是一对长期的 ECDSA P-256 密钥。你只生成它一次,把私钥那一半持久化在设备上,并让它跨越重启、重连和新会话继续存在。它唯一的职责就是为临时密钥签名:"你刚收到的这把临时公钥,确实是由持有这把身份密钥的人发布的"。对端会用随之一起传来的身份公钥验证这个签名,而服务器虽然仍能看到并转发两者,却无法伪造签名,因为它没有身份私钥。

这就把中间人问题归结为一个问题,而且只有一个:这把身份公钥,真的属于我的对端吗?这是一个你的应用程序能够回答的问题,因为与随机的临时密钥不同,身份密钥是稳定的,所以它可以被记住、被固定,并且可以由人来比对。

生成并存储身份密钥

sgcSSL_E2EE 中的一个辅助函数会以 PEM 字符串的形式创建这对密钥。每次安装、每个用户调用一次,然后把私钥存放在你的应用程序存放机密信息的地方。永远不要把私钥发送到任何地方。

uses
  sgcSSL_E2EE;

var
  vPrivateKeyPEM, vPublicKeyPEM: string;
begin
  // ... generate ONCE, then persist. Regenerating it on every start
  // ... defeats the whole point: your peers would see a new identity every time.
  sgcE2EE_CreateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);

  SaveIdentity(vPrivateKeyPEM, vPublicKeyPEM);  // your own secure storage
end;

如果你更愿意把它保留在组件上,也可以直接用 E2EE 客户端的 GenerateIdentityKeyPair 生成同一对密钥:

var
  vPrivateKeyPEM, vPublicKeyPEM: string;
begin
  oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;

如何存储由你决定,而这正是值得慎重对待的部分。私钥就是证明你是你的那样东西。把它放进平台密钥库、放进 DPAPI 保护的二进制块、放进加密的配置文件,放进你的威胁模型认为它该待的地方。一旦它泄露,攻击者就能冒充那个身份,而你的用户比对过的指纹仍然会匹配。

如何启用

身份签名位于 E2EE_Options.Identity 之下。加载已存储的密钥对,设置 Enabled,从那一刻起客户端就会为自己的临时密钥签名,并验证它收到的签名。

uses
  sgcWebSocket, sgcWebSocket_Protocols;

begin
  WSClient := TsgcWebSocketClient.Create(nil);
  WSClient.Host := '127.0.0.1';
  WSClient.Port := 80;

  E2EE := TsgcWSPClient_E2EE.Create(nil);
  E2EE.Client := WSClient;
  E2EE.E2EE_Options.UserId := 'client-1';

  // ... identity verification
  E2EE.E2EE_Options.Identity.Enabled    := True;
  E2EE.E2EE_Options.Identity.PrivateKey := LoadIdentityPrivateKey;  // PEM
  E2EE.E2EE_Options.Identity.PublicKey  := LoadIdentityPublicKey;   // PEM

  E2EE.OnE2EEVerifyPeerIdentity := OnE2EEVerifyPeerIdentityEvent;
  E2EE.OnE2EEKeyChange          := OnE2EEKeyChangeEvent;

  WSClient.Active := True;
end;

从此以后,客户端发布的临时公钥会连同身份公钥以及对该临时密钥的签名一起发出。当对端的密钥到达时,签名会用随它一起到来的身份密钥进行校验。如果签名验证不通过,对端的密钥会被拒绝,不会从它派生出任何共享密钥。

请仔细留意一个有效签名能证明什么、又不能证明什么。它证明这把临时密钥是由那把身份私钥的持有者签署的。但它本身并不能证明那把身份密钥属于你的对端,因为一个中间人完全可以拿出自己的身份密钥,配上自己那个完全有效的签名。最后这一段路,正是接下来两节要解决的。

验证对端:首次使用即信任与指纹固定

一旦签名验证通过,OnE2EEVerifyPeerIdentity 就会触发,并带上对端的用户 id、身份公钥及其指纹。aAccept 参数是 var 类型,它到达时的值是 True,因此如果你什么都不做,对端就会被接受。这个决定被刻意交给你,因为只有你的应用程序才知道它此前是否见过这个联系人。

标准做法是首次使用即信任加上指纹固定。第一次见到一个用户时,记录下指纹。此后每一次,都做比对。如果一致,就静默接受。如果不一致,就不要接受,并告诉用户。

procedure TForm1.OnE2EEVerifyPeerIdentityEvent(Sender: TObject;
  const aUserId, aIdentityPublicKey, aFingerprint: string; var aAccept: Boolean);
var
  vPinned: string;
begin
  vPinned := GetPinnedFingerprint(aUserId);   // '' the first time we see this user

  if vPinned = '' then
  begin
    // ... trust on first use: remember it, and from now on it must not change
    SetPinnedFingerprint(aUserId, aFingerprint);
    aAccept := True;
    DoLog('pinned ' + aUserId + ': ' + aFingerprint);
  end
  else if SameText(vPinned, aFingerprint) then
  begin
    // ... same identity key as last time
    aAccept := True;
  end
  else
  begin
    // ... a different identity key for a user we already know. Refuse it and
    // ... let the user decide, do not silently trust it.
    aAccept := False;
    DoLog('REJECTED ' + aUserId + ': fingerprint mismatch');
  end;
end;

首次使用即信任有一个值得如实说明的局限:如果攻击者在你们第一次接触时就已经在中间了,那你固定下来的就是攻击者。它真正带给你的,是攻击者必须从一开始就在场并且永远在场,而此后任何调换密钥的尝试都会闹出很大动静。如果你还需要堵上首次接触的这个漏洞,那就得在带外比对指纹,这正是下一节的内容。

用户真的能比对的指纹

指纹是身份公钥的摘要,由 sgcE2EE_IdentityFingerprint 生成。持有同一把身份密钥的两个端点会产生相同的指纹,而持有不同密钥的中间人无法产生一个匹配的指纹。

这正是 Signal 的"安全号码"和 WhatsApp 的"安全码"背后的机制。它的价值在于,人可以在一条攻击者无法控制的通道上比对它。Alice 在电话里把它念给 Bob 听,或者两人当面互相展示二维码,或者把它粘贴进一个已经受信任的聊天里。如果两个值一致,中间就没有别人。如果不一致,那就有。

把它以用户真的能念出声的形式呈现出来。把摘要分成若干短块就足够了:

uses
  sgcSSL_E2EE;

function FormatFingerprint(const aFingerprint: string): string;
var
  i: Integer;
begin
  // ... 'A1B2C3D4...' -> 'A1B2 C3D4 ...' so a human can read it over the phone
  Result := '';
  for i := 1 to Length(aFingerprint) do
  begin
    if (i > 1) and ((i - 1) mod 4 = 0) then
      Result := Result + ' ';
    Result := Result + aFingerprint[i];
  end;
end;

procedure TForm1.ShowMyFingerprint;
begin
  lblFingerprint.Caption :=
    FormatFingerprint(sgcE2EE_IdentityFingerprint(LoadIdentityPublicKey));
end;

在应用程序里显示你自己的指纹,在联系人旁边显示对端的指纹,并让用户在比对过两者之后把这个联系人标记为已验证。到了那一刻,加密才真正意味着你的用户以为它意味着的东西。

当对端的身份密钥发生变化

当一个用户出示的身份密钥与你上次看到的不同时,OnE2EEKeyChange 会触发,并把旧指纹和新指纹一并交给你。

要注意你如何呈现这件事。身份密钥变化并不自动等于一次攻击。一个重装了应用、清空了设备,或者在新手机上登录的用户,理所当然会有一把新的身份密钥,而这是最常见的原因。不过,这也正是一次密钥调包会产生的信号,所以这是应当告知用户的时刻,也是你的应用程序可能想要丢弃已固定的指纹、并要求重新做一次带外比对的时刻。

procedure TForm1.OnE2EEKeyChangeEvent(Sender: TObject;
  const aUserId, aOldFingerprint, aNewFingerprint: string);
begin
  // ... "Your security code with <user> has changed."
  // ... Usually a reinstall or a new device. Sometimes not.
  DoLog(Format('identity key changed for %s: %s -> %s',
    [aUserId, aOldFingerprint, aNewFingerprint]));

  ClearPinnedFingerprint(aUserId);           // force a re-verification
  ShowSecurityCodeChangedWarning(aUserId, FormatFingerprint(aNewFingerprint));
end;

把它和上面的处理器搭配起来:OnE2EEKeyChange 告诉你密钥变了,而 OnE2EEVerifyPeerIdentity 是你决定要不要继续交谈的地方。

用 RequireAuthentication 做到失败即拒绝

在设置了 Identity.Enabled、而 RequireAuthentication 保持其默认值 False 的情况下,客户端处于尽力而为模式。出示有效签名的对端会被验证。而完全不出示签名的对端,比如一个较旧的客户端,或者一个没有启用身份的客户端,仍然会被接受。这在推广期间很方便,因为并非每个端点都已经更新,但它不是一道安全边界:攻击者只要把签名剥掉,看起来就像一个旧客户端。

RequireAuthentication := True 就是那个失败即拒绝的开关。一把未签名的对端密钥,或者一把签名验证不通过的密钥,会被拒绝而不是被接受。

// ... reject any peer that does not present a valid identity signature
E2EE.E2EE_Options.Identity.Enabled             := True;
E2EE.E2EE_Options.Identity.PrivateKey          := LoadIdentityPrivateKey;
E2EE.E2EE_Options.Identity.PublicKey           := LoadIdentityPublicKey;
E2EE.E2EE_Options.Identity.RequireAuthentication := True;

分两步推广它。先在 RequireAuthentication 关闭的情况下把身份密钥发布出去,等你的整个客户端群都有了身份密钥之后,再把它打开。一旦打开,剥掉签名的降级攻击就不再奏效了,因为缺失的签名会换来一次拒绝,而不是一个耸肩。

群聊

群组消息享受同样的待遇。每个成员都用自己的身份密钥为自己的临时密钥签名,而身份公钥和签名会随每个成员的条目一起传递,因此加入一个群组时,你会验证你即将向其加密的每一个成员,而不只是邀请你的那个对端。OnE2EEVerifyPeerIdentity 会为每个成员触发一次,所以你为一对一聊天写的那套固定逻辑可以原封不动地套用,而一个你拒绝了其密钥的成员,你也不会与之派生共享密钥。RequireAuthentication 在群组中的作用与在一对一会话中完全一样。

默认向后兼容

Identity.Enabled 开箱即用时是 False。在它关闭的情况下,不会发送身份密钥,不会产生签名,也不会期待签名,密钥交换与以前逐字节相同。在你显式设置这个属性之前,升级这个库不会改变现有 E2EE 部署的任何东西。

身份字段在线上格式中是增量添加的,因此只要 RequireAuthentication 是关闭的,一个启用了身份的客户端仍然可以与一个没有启用的客户端通信。正是这一点,让你可以在一个无法一次性全部更新的客户端群里逐步部署它。

可用性

E2EE 身份验证随 sgcWebSockets 2026.7 发布,支持 Delphi 7 到 13 以及 C++Builder,平台涵盖 Win32/Win64、Linux64、macOS、Android 和 iOS。它是 E2EE 协议的一部分,在 Enterprise 和 All-Access 版本中提供,并且同时适用于一对一消息和群聊。

拥有有效订阅的客户可以从客户专区下载新版本。试用用户可以在 esegece.com/products/websockets/download 获取更新后的安装程序。

有疑问、反馈,或者需要帮助把身份验证接入你的应用?联系我们,回复你的将是编写这些代码的人。