Kiedy wprowadzaliśmy szyfrowanie end-to-end w sgcWebSockets, gwarancja była jasna: Alice i Bob wyprowadzają wspólny sekret za pomocą ECDH, szyfrują za pomocą AES-GCM, a serwer, który przekazuje ruch, nie widzi nic poza szyfrogramem. Ta część jest solidna. Istnieje jednak wcześniejszy krok, którego samo szyfrowanie nie potrafi ochronić, i to właśnie w nim dochodzi do prawdziwych ataków: Alice musi skądś zdobyć klucz publiczny Boba, a zdobywa go przez serwer.
Wyobraź sobie ten atak konkretnie. Alice prosi serwer o klucz publiczny Boba. Skompromitowany serwer, wrogi przekaźnik albo ktokolwiek, kto przejął ten węzeł, nie przekazuje klucza Boba. Generuje własną parę kluczy i wręcza Alice swój klucz publiczny opisany jako "Bob". Następnie robi to samo w drugą stronę, wręczając Bobowi inny własny klucz, opisany jako "Alice". Alice szyfruje teraz bez zarzutu, tyle że do atakującego. Atakujący odszyfrowuje, czyta, ewentualnie zmienia treść, szyfruje ponownie prawdziwym kluczem Boba i przekazuje dalej. Bob odszyfrowuje bez zarzutu, tyle że wiadomość od atakującego. Obie strony widzą zieloną kłódkę. Obie strony są w błędzie. To klasyczna podmiana klucza w ataku man-in-the-middle i żadna ilość AES-GCM tego nie naprawi, ponieważ obie połowy rozmowy są naprawdę i poprawnie zaszyfrowane. Tyle że zaszyfrowane do niewłaściwej osoby.
Od wersji 2026.7 sgcWebSockets zamyka tę lukę. Każdy punkt końcowy może posiadać długoterminowy klucz tożsamości, podpisać nim swój efemeryczny klucz szyfrujący, a druga strona weryfikuje ten podpis, zanim w ogóle wyprowadzi wspólny sekret. To twoja aplikacja decyduje, co zrobić z odciskiem palca rozmówcy: zaakceptować go przy pierwszym użyciu i przypiąć, porównać poza kanałem albo odrzucić wszystko, co nie jest w ogóle podpisane. Funkcja jest domyślnie wyłączona, a format przesyłany po sieci pozostaje niezmieniony, dopóki jej nie włączysz.
Szyfrowanie to nie uwierzytelnianie
To dwie różne własności i warto wprost powiedzieć, którą z nich się dysponuje.
Szyfrowanie odpowiada na pytanie "czy osoba trzecia może to przeczytać?". ECDH w połączeniu z AES-GCM odpowiada na nie: nie. Uwierzytelnianie odpowiada na pytanie "czy klucz, którym zaszyfrowałem, naprawdę należy do osoby, z którą sądzę, że rozmawiam?". Samo ECDH w ogóle na to nie odpowiada. Bez wahania uzgodni wspólny sekret z każdym, kto przysłał klucz, i nie ma żadnego zdania na temat tego, kto to był.
Cały ten wpis dotyczy drugiego pytania. Nic z opisanych tu zmian nie dotyka zestawu szyfrów, wyprowadzania kluczy ani formatu wiadomości. Dodają one podpis nad kluczem efemerycznym oraz miejsce, w którym twoja aplikacja może powiedzieć tak lub nie stronie stojącej za tym kluczem.
Długoterminowe klucze tożsamości
W sesji E2EE występują teraz dwa rodzaje kluczy i cała idea polega na tym, żeby ich nie mylić.
Efemeryczny klucz szyfrujący to para kluczy ECDH, z której klient korzystał już wcześniej. Istnieje na czas sesji, to z niej wyprowadzany jest wspólny sekret i można ją swobodnie generować od nowa. Nie mówi nic o tym, kim jesteś.
Klucz tożsamości to długoterminowa para kluczy ECDSA P-256. Generujesz ją raz, zapisujesz trwale część prywatną na urządzeniu i zachowujesz ją między restartami, ponownymi połączeniami i nowymi sesjami. Jej jedynym zadaniem jest podpisanie klucza efemerycznego: "efemeryczny klucz publiczny, który właśnie otrzymałeś, naprawdę został opublikowany przez posiadacza tego klucza tożsamości". Druga strona weryfikuje ten podpis publicznym kluczem tożsamości, który przyszedł razem z nim, a serwer, który nadal widzi i przekazuje jedno i drugie, nie może sfałszować podpisu, ponieważ nie ma prywatnego klucza tożsamości.
To sprowadza problem man-in-the-middle do jednego pytania, i tylko jednego: czy ten publiczny klucz tożsamości naprawdę należy do mojego rozmówcy? Na to pytanie twoja aplikacja potrafi odpowiedzieć, ponieważ w przeciwieństwie do losowego klucza efemerycznego klucz tożsamości jest stały, więc można go zapamiętać, przypiąć i porównać przez człowieka.
Generowanie i przechowywanie klucza tożsamości
Funkcja pomocnicza w module sgcSSL_E2EE tworzy parę kluczy w postaci łańcuchów PEM. Wywołaj ją raz na instalację, dla każdego użytkownika, i zapisz klucz prywatny tam, gdzie twoja aplikacja przechowuje sekrety. Nigdy nigdzie nie wysyłaj klucza prywatnego.
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;
Tę samą parę kluczy można też wygenerować bezpośrednio z klienta E2EE za pomocą GenerateIdentityKeyPair, jeśli wolisz trzymać ją na komponencie:
var
vPrivateKeyPEM, vPublicKeyPEM: string;
begin
oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;
Sposób przechowywania to twoja decyzja i to właśnie ta część zasługuje na uwagę. Klucz prywatny jest tym, co dowodzi, że jesteś sobą. Umieść go w magazynie kluczy platformy, w blobie chronionym przez DPAPI, w zaszyfrowanym pliku ustawień, wszędzie tam, gdzie według twojego modelu zagrożeń jest jego miejsce. Jeśli wycieknie, atakujący będzie mógł podszyć się pod tę tożsamość, a odcisk palca, który porównali twoi użytkownicy, nadal będzie się zgadzał.
Włączanie funkcji
Podpisywanie tożsamości znajduje się w E2EE_Options.Identity. Wczytaj zapisaną parę kluczy, ustaw Enabled, a klient od tego momentu podpisuje swój efemeryczny klucz i weryfikuje otrzymywane podpisy.
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;
Od tej chwili efemerycznemu kluczowi publicznemu, który publikuje klient, towarzyszy publiczny klucz tożsamości oraz podpis nad tym kluczem efemerycznym. Gdy przychodzi klucz drugiej strony, podpis jest sprawdzany względem klucza tożsamości, który przyszedł razem z nim. Jeśli podpis się nie weryfikuje, klucz drugiej strony zostaje odrzucony i nie wyprowadza się z niego żadnego wspólnego sekretu.
Zwróć uwagę, czego poprawny podpis dowodzi, a czego nie. Dowodzi, że klucz efemeryczny został podpisany przez posiadacza tego właśnie prywatnego klucza tożsamości. Sam w sobie nie dowodzi, że ten klucz tożsamości należy do twojego rozmówcy, ponieważ atakujący pośrodku może przedstawić własny klucz tożsamości z własnym, całkowicie poprawnym podpisem. Tym ostatnim odcinkiem zajmują się dwie kolejne sekcje.
Weryfikacja rozmówcy: zaufanie przy pierwszym użyciu i przypinanie
Gdy podpis zostanie zweryfikowany, uruchamia się zdarzenie OnE2EEVerifyPeerIdentity z identyfikatorem użytkownika rozmówcy, publicznym kluczem tożsamości i jego odciskiem palca. Parametr aAccept jest przekazywany przez var i przychodzi z wartością True, więc jeśli nic nie zrobisz, rozmówca zostanie zaakceptowany. Decyzja celowo należy do ciebie, bo tylko twoja aplikacja wie, czy widziała już wcześniej ten kontakt.
Standardowy wzorzec to zaufanie przy pierwszym użyciu połączone z przypinaniem. Gdy widzisz użytkownika po raz pierwszy, zapisz odcisk palca. Za każdym kolejnym razem porównuj. Jeśli się zgadza, akceptuj po cichu. Jeśli się nie zgadza, nie akceptuj i powiadom użytkownika.
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;
Zaufanie przy pierwszym użyciu ma uczciwe ograniczenie, o którym warto powiedzieć: jeśli atakujący był już pośrodku przy tym pierwszym kontakcie, przypniesz atakującego. Zyskujesz jednak tyle, że atakujący musi być obecny od samego początku i pozostać tam na zawsze, a każda późniejsza próba podmiany klucza jest głośna. Jeśli chcesz zamknąć również lukę pierwszego kontaktu, porównujesz odcisk palca poza kanałem, i o tym jest następna sekcja.
Odciski palca, które twoi użytkownicy naprawdę mogą porównać
Odcisk palca to skrót publicznego klucza tożsamości, wyliczany przez sgcE2EE_IdentityFingerprint. Dwa punkty końcowe posiadające ten sam klucz tożsamości dają ten sam odcisk palca, a atakujący pośrodku, mający inny klucz, nie jest w stanie wytworzyć pasującego.
To dokładnie ten mechanizm, który stoi za "numerem bezpieczeństwa" w Signalu i "kodem bezpieczeństwa" w WhatsAppie. Jego wartość polega na tym, że człowiek może go porównać kanałem, którego atakujący nie kontroluje. Alice odczytuje go Bobowi przez telefon, pokazują sobie nawzajem kod QR na żywo albo wklejają go do istniejącego, zaufanego czatu. Jeśli obie wartości się zgadzają, nikogo pośrodku nie ma. Jeśli się różnią, ktoś tam jest.
Podaj go użytkownikom w formie, którą naprawdę da się odczytać na głos. Wystarczy pogrupować skrót w krótkie bloki:
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;
Pokaż własny odcisk palca w aplikacji, pokaż odcisk palca rozmówcy obok kontaktu i pozwól użytkownikowi oznaczyć kontakt jako zweryfikowany, gdy porówna obie wartości. To moment, w którym szyfrowanie naprawdę znaczy to, co twoi użytkownicy sądzą, że znaczy.
Gdy klucz tożsamości rozmówcy się zmienia
Zdarzenie OnE2EEKeyChange uruchamia się, gdy użytkownik przedstawia klucz tożsamości inny niż ten, który widziałeś u niego ostatnio, i przekazuje ci zarówno stary, jak i nowy odcisk palca.
Uważaj na to, jak to przedstawisz. Zmieniony klucz tożsamości nie oznacza automatycznie ataku. Użytkownik, który przeinstalował aplikację, wyczyścił urządzenie albo zalogował się na nowym telefonie, całkiem legalnie będzie miał nowy klucz tożsamości i jest to zdecydowanie najczęstsza przyczyna. Jest to jednak dokładnie ten sam sygnał, jaki wywołałaby podmiana klucza, więc to moment, w którym należy powiadomić użytkownika, oraz moment, w którym twoja aplikacja może chcieć usunąć przypięcie i poprosić o nowe porównanie poza kanałem.
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;
Połącz to z procedurą obsługi opisaną wyżej: OnE2EEKeyChange mówi ci, że klucz się zmienił, a w OnE2EEVerifyPeerIdentity decydujesz, czy dalej rozmawiać.
Bezpieczne odrzucanie dzięki RequireAuthentication
Przy ustawionym Identity.Enabled i RequireAuthentication pozostawionym na domyślnej wartości False klient działa w trybie best-effort. Rozmówcy przedstawiający poprawny podpis są weryfikowani. Rozmówcy, którzy nie przedstawiają żadnego podpisu, czyli starszy klient albo klient bez włączonej tożsamości, nadal są akceptowani. To wygodne w trakcie wdrażania, gdy nie każdy punkt końcowy został jeszcze zaktualizowany, ale nie jest to granica bezpieczeństwa: atakujący może po prostu usunąć podpis i wyglądać jak stary klient.
RequireAuthentication := True to przełącznik bezpiecznego odrzucania. Niepodpisany klucz rozmówcy albo taki, którego podpis się nie weryfikuje, zostaje odrzucony zamiast zaakceptowany.
// ... 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;
Wdrażaj to w dwóch krokach. Najpierw dostarcz klucze tożsamości z wyłączonym RequireAuthentication, poczekaj, aż będzie je miała cała twoja flota, a potem włącz opcję. Gdy jest włączona, atak polegający na obniżeniu zabezpieczeń przez usunięcie podpisu przestaje działać, ponieważ brak podpisu oznacza odrzucenie, a nie wzruszenie ramionami.
Czaty grupowe
Wiadomości grupowe są traktowane tak samo. Każdy uczestnik podpisuje swój efemeryczny klucz własnym kluczem tożsamości, a publiczny klucz tożsamości i podpis podróżują razem z wpisem każdego uczestnika, więc dołączenie do grupy weryfikuje każdego uczestnika, do którego zamierzasz szyfrować, a nie tylko osobę, która cię zaprosiła. Zdarzenie OnE2EEVerifyPeerIdentity uruchamia się dla każdego uczestnika, więc ta sama logika przypinania, którą napisałeś dla rozmów jeden na jeden, działa bez zmian, a z uczestnikiem, którego klucz odrzucisz, nie wyprowadzasz żadnego sekretu. RequireAuthentication działa w grupie dokładnie tak samo jak w rozmowie bezpośredniej.
Domyślnie zgodne wstecz
Identity.Enabled ma domyślnie wartość False. Gdy jest wyłączone, żaden klucz tożsamości nie jest wysyłany, żaden podpis nie jest tworzony ani oczekiwany, a wymiana kluczy przebiega bajt w bajt tak jak wcześniej. Aktualizacja biblioteki niczego nie zmienia w istniejącym wdrożeniu E2EE, dopóki jawnie nie ustawisz tej właściwości.
Pola tożsamości są dodawane do formatu przesyłanego po sieci, więc klient z włączoną tożsamością nadal rozmawia z klientem bez niej, o ile RequireAuthentication jest wyłączone. To właśnie pozwala wdrażać tę funkcję stopniowo we flocie, której nie aktualizujesz naraz.
Dostępność
Weryfikacja tożsamości E2EE jest dostępna w sgcWebSockets 2026.7 dla Delphi 7 do 13 oraz C++Builder, na Win32/Win64, Linux64, macOS, Android i iOS. Stanowi część protokołu E2EE, dostępnego w edycjach Enterprise i All-Access, i działa zarówno dla wiadomości jeden na jeden, jak i dla czatów grupowych.
Klienci z aktywną subskrypcją mogą pobrać nową wersję ze strefy klienta. Użytkownicy wersji próbnej mogą pobrać zaktualizowany instalator ze strony esegece.com/products/websockets/download.
Pytania, uwagi albo pomoc we wpięciu weryfikacji tożsamości do twojej aplikacji? Skontaktuj się z nami, odpowiedź otrzymasz od osób, które napisały ten kod.
