E2EE の本人性検証: 途中での鍵すり替えを止める | eSeGeCe Blog

E2EE の本人性検証: 途中での鍵すり替えを止める

· コンポーネント

sgcWebSockets のエンドツーエンド暗号化を導入したとき、保証する内容は明快でした。Alice と Bob が ECDH で共有秘密を導出し、AES-GCM で暗号化し、トラフィックを中継するサーバーには暗号文しか見えない、というものです。その部分は堅固です。しかし、そのすべての前に、暗号化そのものでは守れないステップがあります。そして現実の攻撃はまさにそこで起きます。Alice はどこかから Bob の公開鍵を入手しなければならず、それはサーバー経由で入手されるのです。

攻撃を具体的に思い描いてみましょう。Alice がサーバーに Bob の公開鍵を要求します。侵害されたサーバー、不正な中継、あるいはその経路を乗っ取った何者かは、Bob の鍵を転送しません。自分自身の鍵ペアを生成し、その公開鍵に「Bob」というラベルを付けて Alice に渡します。次に逆方向にも同じことをして、別の自分の鍵に「Alice」というラベルを付けて Bob に渡します。Alice は完璧に暗号化しますが、宛先は攻撃者です。攻撃者は復号し、読み、場合によっては書き換え、Bob の本物の鍵で再暗号化して転送します。Bob は完璧に復号しますが、送り主は攻撃者です。両者とも緑の南京錠を目にしています。両者とも間違っています。これが古典的な中間者による鍵すり替えであり、AES-GCM をどれだけ重ねても解決しません。会話の両半分は、本当に、正しく暗号化されているからです。ただ、暗号化の相手が違うだけなのです。

バージョン 2026.7 から、sgcWebSockets はこのギャップを塞ぎます。各エンドポイントは長期の識別鍵を保持し、それで一時的な暗号鍵に署名できます。相手は共有秘密を導出する前にその署名を検証します。相手のフィンガープリントをどう扱うかはアプリケーションが決めます。初回使用時に受け入れてピン留めする、帯域外で比較する、あるいは署名のないものを一切拒否する、といった具合です。既定では無効で、有効にするまでワイヤーフォーマットは変わりません。

暗号化は認証ではない

これらは 2 つの異なる性質であり、自分がどちらを手にしているのかをはっきりさせる価値があります。

暗号化は「第三者はこれを読めるか」に答えます。ECDH と AES-GCM の組み合わせは、それに「いいえ」と答えます。認証は「私が暗号化した相手の鍵は、本当に自分が話していると思っている相手の鍵なのか」に答えます。素の ECDH はこれにまったく答えません。鍵を送ってきた相手が誰であれ喜んで共有秘密に合意しますし、それが誰だったかについて何の見解も持ちません。

この記事の内容はすべて 2 つめの問いについてです。ここで暗号スイート、鍵導出、メッセージフォーマットが変わることはありません。追加されるのは、一時鍵に対する署名と、その背後にいる相手にアプリケーションが「はい」か「いいえ」と言える場所です。

長期の識別鍵

E2EE セッションには 2 種類の鍵が存在するようになり、この 2 つを混同しないことがすべてです。

一時的な暗号鍵は、クライアントがすでに使っていた ECDH の鍵ペアです。セッションの間だけ存在し、共有秘密の導出元となり、自由に再生成できます。あなたが誰であるかについては何も語りません。

識別鍵は長期の ECDSA P-256 鍵ペアです。一度だけ生成し、秘密鍵側をデバイスに永続化し、再起動や再接続、新しいセッションをまたいで保持します。その唯一の仕事は一時鍵に署名することです。つまり「いま受け取った一時公開鍵は、確かにこの識別鍵の保持者が公開したものだ」と示すのです。相手は、その署名を一緒に届いた識別公開鍵で検証します。サーバーは依然として両方を見て中継できますが、識別秘密鍵を持っていないので署名を偽造できません。

これにより中間者問題はただ 1 つの問いに還元されます。この識別公開鍵は本当に自分の相手のものなのか、それだけです。これはアプリケーションが答えられる問いです。ランダムな一時鍵とは違い、識別鍵は安定しているので、記憶し、ピン留めし、人間が比較できるからです。

識別鍵の生成と保存

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;

これ以降、クライアントが公開する一時公開鍵には、識別公開鍵とその一時鍵に対する署名が添えられます。相手の鍵が届くと、その署名は一緒に届いた識別鍵を使って検証されます。署名が検証できなければ相手の鍵は拒否され、そこから共有秘密が導出されることはありません。

有効な署名が何を証明し、何を証明しないかに注意してください。それは、一時鍵がその識別秘密鍵の保持者によって署名されたことを証明します。しかしそれ自体では、その識別鍵があなたの相手のものであることは証明しません。中間者は自分の識別鍵を、自分の完全に有効な署名とともに提示できるからです。その最後の一区間を埋めるのが、次の 2 つのセクションです。

相手を検証する: 初回使用時の信頼とピン留め

署名の検証が通ると、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 が生成します。同じ識別鍵を持つ 2 つのエンドポイントは同じフィンガープリントを生成し、異なる鍵を持つ中間者は一致するものを作り出せません。

これはまさに Signal の「安全番号」や WhatsApp の「セキュリティコード」の背後にある仕組みです。その価値は、攻撃者が支配していないチャネルを通じて人間が比較できる点にあります。Alice が電話で Bob に読み上げる、直接会って QR コードを見せ合う、あるいは既存の信頼できるチャットに貼り付ける、といった具合です。2 つの値が一致すれば、中間には誰もいません。違っていれば、誰かがいます。

ユーザーには、実際に声に出して読める形で提示してください。ダイジェストを短いブロックに区切るだけで十分です。

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;

展開は 2 段階で行ってください。まず RequireAuthentication をオフにしたまま識別鍵を配布し、環境全体が鍵を持つまで待ってから、有効にします。いったん有効にすれば、署名を取り除くダウングレード攻撃はもはや通用しません。署名がないことは、肩をすくめて見過ごされるのではなく、拒否を意味するようになるからです。

グループチャット

グループメッセージングも同じ扱いを受けます。各メンバーが自分の識別鍵で一時鍵に署名し、識別公開鍵と署名が各メンバーのエントリとともに届きます。そのため、グループへの参加時には、招待してくれた相手だけでなく、これから暗号化して送る相手全員を検証することになります。OnE2EEVerifyPeerIdentity はメンバーごとに発火するので、1 対 1 のチャット用に書いたピン留めのロジックがそのまま適用できますし、鍵を拒否したメンバーとは共有秘密を導出しません。RequireAuthentication はグループでも、1 対 1 の会話とまったく同じように機能します。

既定では後方互換

Identity.Enabled は初期状態で False です。オフの間は識別鍵は送られず、署名も生成されず、期待もされず、鍵交換は以前とバイト単位でまったく同じです。ライブラリをアップグレードしても、明示的にこのプロパティを設定するまで、既存の E2EE 環境は何も変わりません。

識別関連のフィールドはワイヤー上で追加的なものなので、RequireAuthentication がオフである限り、識別を有効にしたクライアントは有効にしていないクライアントとも通信できます。だからこそ、一度にすべてを更新できない環境にも段階的に展開できるのです。

提供状況

E2EE の本人性検証は sgcWebSockets 2026.7 で、Delphi 7 から 13 および C++Builder 向けに、Win32/Win64、Linux64、macOS、Android、iOS で提供されます。これは E2EE プロトコルの一部であり、Enterprise および All-Access エディションで利用でき、1 対 1 のメッセージにもグループチャットにも機能します。

有効なサブスクリプションをお持ちのお客様は、カスタマーエリアから新しいビルドをダウンロードできます。トライアルをご利用の方は esegece.com/products/websockets/download から更新版のインストーラーを入手できます。

ご質問、ご意見、あるいは本人性検証をアプリに組み込むお手伝いが必要ですか。お問い合わせください。コードを書いた本人から返信が届きます。