E2EE 신원 검증: 중간에서 일어나는 키 바꿔치기 막기 | eSeGeCe 블로그

E2EE 신원 검증: 중간에서 일어나는 키 바꿔치기 막기

· 컴포넌트

sgcWebSockets의 종단 간 암호화를 소개했을 때, 보장 내용은 분명했습니다. Alice와 Bob이 ECDH로 공유 비밀을 도출하고 AES-GCM으로 암호화하면, 트래픽을 중계하는 서버는 암호문 말고는 아무것도 보지 못합니다. 그 부분은 견고합니다. 하지만 그 모든 것에 앞서, 암호화 자체로는 보호할 수 없는 단계가 하나 있고, 실제 공격은 바로 그 단계에서 일어납니다. Alice는 어딘가에서 Bob의 공개 키를 받아와야 하는데, 그것을 서버를 통해 받습니다.

공격을 구체적으로 그려 봅시다. Alice가 서버에 Bob의 공개 키를 요청합니다. 침해된 서버, 악성 릴레이, 혹은 그 구간을 장악한 누군가는 Bob의 키를 전달하지 않습니다. 대신 자기 키 쌍을 만들어서 자신의 공개 키에 "Bob"이라는 이름표를 붙여 Alice에게 건넵니다. 그리고 반대 방향으로도 똑같이 해서, 또 다른 자기 키에 "Alice"라는 이름표를 붙여 Bob에게 건넵니다. 이제 Alice는 완벽하게 암호화합니다. 공격자에게 말이죠. 공격자는 복호화하고, 읽고, 어쩌면 내용을 바꾸고, Bob의 진짜 키로 다시 암호화해서 전달합니다. Bob도 완벽하게 복호화합니다. 공격자로부터 말이죠. 양쪽 모두 녹색 자물쇠를 봅니다. 양쪽 모두 틀렸습니다. 이것이 고전적인 중간자 키 바꿔치기이며, 대화의 양쪽 절반이 모두 진짜로, 올바르게 암호화되어 있기 때문에 AES-GCM을 아무리 써도 해결되지 않습니다. 그저 엉뚱한 사람에게 암호화되어 있을 뿐입니다.

버전 2026.7부터 sgcWebSockets는 이 빈틈을 메웁니다. 각 엔드포인트는 장기 신원 키를 보유하고, 그것으로 자신의 임시 암호화 키에 서명할 수 있으며, 피어는 공유 비밀을 도출하기 전에 그 서명을 검증합니다. 피어의 지문을 어떻게 다룰지는 애플리케이션이 결정합니다. 최초 사용 시 수락하고 고정하거나, 대역 외로 비교하거나, 서명이 없는 것은 아예 거부할 수 있습니다. 기본값은 꺼짐이며, 직접 켜기 전까지 통신 형식은 그대로입니다.

암호화는 인증이 아니다

이 둘은 서로 다른 속성이며, 자신이 어느 쪽을 가지고 있는지 솔직하게 짚어 볼 가치가 있습니다.

암호화는 "제3자가 이것을 읽을 수 있는가?"에 답합니다. 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가 피어의 사용자 아이디, 신원 공개 키, 그리고 그 지문과 함께 발생합니다. 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에게 지문을 읽어 주거나, 직접 만나 QR 코드를 서로 보여 주거나, 이미 신뢰하고 있는 채팅에 붙여 넣습니다. 두 값이 일치하면 중간에 아무도 없습니다. 다르면 누군가 있습니다.

사용자가 실제로 소리 내어 읽을 수 있는 형태로 보여 주십시오. 다이제스트를 짧은 블록으로 묶는 것만으로도 충분합니다.

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에서 업데이트된 설치 파일을 받을 수 있습니다.

질문이나 의견이 있거나, 앱에 신원 검증을 연결하는 데 도움이 필요하신가요? 문의하기, 코드를 직접 작성한 사람들이 답변해 드립니다.