Toen we End-To-End-encryptie in sgcWebSockets introduceerden, was de garantie duidelijk: Alice en Bob leiden met ECDH een gedeeld geheim af, versleutelen met AES-GCM, en de server die het verkeer routeert ziet niets anders dan cijfertekst. Dat deel is solide. Maar er is een stap vóór dat alles die de versleuteling zelf niet kan beschermen, en het is precies de stap waar echte aanvallen plaatsvinden: Alice moet Bobs publieke sleutel ergens vandaan halen, en ze krijgt hem via de server.
Stel u de aanval concreet voor. Alice vraagt de server om Bobs publieke sleutel. Een gecompromitteerde server, een malafide relay, of wie dan ook die die hop heeft overgenomen, stuurt Bobs sleutel niet door. Hij genereert een eigen sleutelpaar en overhandigt Alice zijn eigen publieke sleutel, met het label "Bob". Vervolgens doet hij hetzelfde in de andere richting en geeft Bob een andere eigen sleutel, met het label "Alice". Alice versleutelt nu perfect, naar de aanvaller. De aanvaller ontsleutelt, leest mee, herschrijft eventueel, versleutelt opnieuw naar Bobs echte sleutel, en stuurt door. Bob ontsleutelt perfect, van de aanvaller. Beide kanten zien een groen hangslot. Beide kanten hebben het mis. Dit is de klassieke man-in-the-middle-sleutelwissel, en geen enkele hoeveelheid AES-GCM verhelpt dat, want beide helften van het gesprek zijn echt en correct versleuteld. Ze zijn alleen naar de verkeerde persoon versleuteld.
Vanaf versie 2026.7 dicht sgcWebSockets dit gat. Elk endpoint kan een langlevende identiteitssleutel bezitten, zijn kortstondige versleutelingssleutel daarmee ondertekenen, en de peer verifieert die handtekening voordat hij ook maar een gedeeld geheim afleidt. Uw applicatie bepaalt wat er met de vingerafdruk van de peer gebeurt: hem accepteren bij het eerste gebruik en vastpinnen, hem buiten de verbinding om vergelijken, of alles weigeren wat helemaal niet ondertekend is. Het staat standaard uit, en het formaat op de lijn blijft ongewijzigd totdat u het inschakelt.
Versleuteling is geen authenticatie
Dit zijn twee verschillende eigenschappen, en het is de moeite waard om er onomwonden over te zijn welke u hebt.
Versleuteling beantwoordt de vraag "kan een derde partij dit lezen?". ECDH plus AES-GCM beantwoordt die: nee. Authenticatie beantwoordt de vraag "is de sleutel waarnaar ik versleuteld heb werkelijk de sleutel van de persoon met wie ik denk te praten?". Gewone ECDH beantwoordt die vraag helemaal niet. Ze komt maar al te graag tot een gedeeld geheim met wie de sleutel ook stuurde, en ze heeft geen mening over wie dat was.
Alles in dit bericht gaat over de tweede vraag. Niets hier verandert de cipher suite, de sleutelafleiding of het berichtformaat. Het voegt een handtekening over de kortstondige sleutel toe, en een plek waar uw applicatie ja of nee kan zeggen tegen de peer erachter.
Langlevende identiteitssleutels
Er zijn nu twee soorten sleutels in een E2EE-sessie, en ze uit elkaar houden is de hele kern.
De kortstondige versleutelingssleutel is het ECDH-sleutelpaar dat de client al gebruikte. Het bestaat voor de duur van de sessie, het is waaruit het gedeelde geheim wordt afgeleid, en het kan vrijelijk opnieuw worden gegenereerd. Het zegt niets over wie u bent.
De identiteitssleutel is een langlevend ECDSA P-256-sleutelpaar. U genereert het één keer, u bewaart de private helft op het apparaat, en u houdt het over herstarts, herverbindingen en nieuwe sessies heen. Zijn enige taak is het ondertekenen van de kortstondige sleutel: "de kortstondige publieke sleutel die u zojuist ontving, is werkelijk gepubliceerd door de houder van deze identiteitssleutel". De peer verifieert die handtekening met de publieke identiteitssleutel die ernaast meereisde, en de server, die beide nog steeds kan zien en doorgeven, kan de handtekening niet vervalsen, omdat hij de private identiteitssleutel niet heeft.
Dat reduceert het man-in-the-middle-probleem tot één vraag, en slechts één: is deze publieke identiteitssleutel werkelijk die van mijn peer? Dat is een vraag die uw applicatie kan beantwoorden, want anders dan een willekeurige kortstondige sleutel is een identiteitssleutel stabiel, zodat hij kan worden onthouden, vastgepind en door een mens vergeleken.
Een identiteitssleutel genereren en opslaan
Een helper in sgcSSL_E2EE maakt het sleutelpaar aan als PEM-strings. Roep hem één keer aan per installatie, per gebruiker, en sla de private sleutel op waar uw applicatie geheimen bewaart. Stuur de private sleutel nooit ergens naartoe.
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;
Hetzelfde sleutelpaar kan ook vanuit de E2EE-client zelf worden gegenereerd met GenerateIdentityKeyPair, als u het liever op de component houdt:
var
vPrivateKeyPEM, vPublicKeyPEM: string;
begin
oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;
De opslag is uw beslissing, en het is het onderdeel dat zorg verdient. De private sleutel is het ding dat bewijst dat u het bent. Zet hem in de keystore van het platform, in een met DPAPI beschermde blob, in een versleuteld instellingenbestand, waar uw dreigingsmodel ook zegt dat hij hoort. Als hij lekt, kan een aanvaller die identiteit nabootsen, en de vingerafdruk die uw gebruikers vergeleken zal nog steeds overeenkomen.
Inschakelen
Identiteitsondertekening leeft onder E2EE_Options.Identity. Laad het opgeslagen sleutelpaar, zet Enabled, en de client ondertekent vanaf dat moment zijn kortstondige sleutel en verifieert de handtekeningen die hij ontvangt.
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;
Vanaf hier gaat de kortstondige publieke sleutel die de client publiceert vergezeld van de publieke identiteitssleutel en een handtekening over die kortstondige sleutel. Wanneer de sleutel van een peer binnenkomt, wordt de handtekening gecontroleerd tegen de identiteitssleutel die ermee meekwam. Verifieert de handtekening niet, dan wordt de sleutel van de peer geweigerd en wordt er geen gedeeld geheim uit afgeleid.
Let goed op wat een geldige handtekening wel en niet bewijst. Ze bewijst dat de kortstondige sleutel is ondertekend door de houder van die private identiteitssleutel. Ze bewijst op zichzelf niet dat die identiteitssleutel van uw peer is, want een man in the middle kan zijn eigen identiteitssleutel presenteren met zijn eigen volkomen geldige handtekening. Voor die laatste meter zijn de volgende twee secties bedoeld.
Een peer verifiëren: vertrouwen bij eerste gebruik en vastpinnen
Zodra een handtekening verifieert, wordt OnE2EEVerifyPeerIdentity afgevuurd met het gebruikers-id van de peer, de publieke identiteitssleutel en de vingerafdruk daarvan. De parameter aAccept is een var, en hij komt binnen als True, dus als u niets doet wordt de peer geaccepteerd. De beslissing is bewust aan u, want alleen uw applicatie weet of ze dit contact eerder heeft gezien.
Het standaardpatroon is vertrouwen bij eerste gebruik plus vastpinnen. De eerste keer dat u een gebruiker ziet, legt u de vingerafdruk vast. Elke keer daarna vergelijkt u. Komt hij overeen, accepteer dan stilzwijgend. Komt hij niet overeen, accepteer dan niet, en vertel het de gebruiker.
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;
Vertrouwen bij eerste gebruik heeft een eerlijke beperking die het vermelden waard is: zat de aanvaller bij dat allereerste contact al in het midden, dan pint u de aanvaller vast. Wat het u wel oplevert, is dat de aanvaller er vanaf het begin moet zijn en er voor altijd moet blijven, en dat elke latere poging om de sleutel te wisselen luidruchtig is. Wilt u ook het gat bij het eerste contact dichten, dan vergelijkt u de vingerafdruk buiten de verbinding om, en dat is de volgende sectie.
Vingerafdrukken die uw gebruikers echt kunnen vergelijken
Een vingerafdruk is een digest van de publieke identiteitssleutel, geproduceerd door sgcE2EE_IdentityFingerprint. Twee endpoints met dezelfde identiteitssleutel produceren dezelfde vingerafdruk, en een man in the middle met een andere sleutel kan er geen overeenkomende produceren.
Dit is precies het mechanisme achter het "safety number" van Signal en de "security code" van WhatsApp. De waarde ervan is dat het door een mens vergeleken kan worden via een kanaal dat de aanvaller niet beheerst. Alice leest het aan de telefoon voor aan Bob, of ze tonen elkaar in levenden lijve een QR-code, of ze plakken het in een bestaande vertrouwde chat. Komen de twee waarden overeen, dan zit er niemand tussen. Verschillen ze, dan wel.
Geef het aan uw gebruikers in een vorm die ze echt hardop kunnen voorlezen. De digest in korte blokjes groeperen is genoeg:
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;
Toon uw eigen vingerafdruk in de applicatie, toon de vingerafdruk van de peer naast het contact, en laat de gebruiker een contact als geverifieerd markeren zodra hij de twee heeft vergeleken. Dat is het punt waarop de versleuteling daadwerkelijk betekent wat uw gebruikers denken dat ze betekent.
Wanneer de identiteitssleutel van een peer verandert
OnE2EEKeyChange wordt afgevuurd wanneer een gebruiker een andere identiteitssleutel presenteert dan de sleutel die u het laatst van hem zag, en het geeft u zowel de oude als de nieuwe vingerafdruk.
Wees voorzichtig met hoe u dit presenteert. Een gewijzigde identiteitssleutel is niet automatisch een aanval. Een gebruiker die de app opnieuw heeft geïnstalleerd, een apparaat heeft gewist of zich op een nieuwe telefoon heeft aangemeld, heeft volkomen legitiem een nieuwe identiteitssleutel, en dat is veruit de meest voorkomende oorzaak. Het is echter precies het signaal dat een sleutelwissel zou produceren, dus het is het moment waarop de gebruiker het te horen moet krijgen, en het moment waarop uw applicatie de pin misschien wil laten vallen en om een nieuwe vergelijking buiten de verbinding om wil vragen.
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;
Combineer het met de handler hierboven: OnE2EEKeyChange vertelt u dat de sleutel is verschoven, OnE2EEVerifyPeerIdentity is waar u beslist of u blijft praten.
Alles dichtzetten met RequireAuthentication
Met Identity.Enabled aan en RequireAuthentication op de standaardwaarde False zit de client in een best-effort modus. Peers die een geldige handtekening presenteren worden geverifieerd. Peers die helemaal geen handtekening presenteren, een oudere client, een client die identiteit niet heeft ingeschakeld, worden nog steeds geaccepteerd. Dat is handig tijdens een uitrol, wanneer nog niet elk endpoint is bijgewerkt, maar het is geen beveiligingsgrens: een aanvaller kan de handtekening simpelweg weglaten en er als een oude client uitzien.
RequireAuthentication := True is de schakelaar die alles dichtzet. Een niet-ondertekende peer-sleutel, of een sleutel waarvan de handtekening niet verifieert, wordt geweigerd in plaats van geaccepteerd.
// ... 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;
Rol het in twee stappen uit. Lever identiteitssleutels uit met RequireAuthentication uit, wacht tot uw hele park ze heeft, en zet het dan aan. Zodra het aan staat, werkt een downgrade-aanval die de handtekening verwijdert niet meer, want een ontbrekende handtekening is dan een weigering in plaats van een schouderophalen.
Groepschats
Groepsberichten krijgen dezelfde behandeling. Elk lid ondertekent zijn kortstondige sleutel met zijn eigen identiteitssleutel, en de publieke identiteitssleutel en de handtekening reizen mee met de vermelding van elk lid, dus bij het toetreden tot een groep verifieert u elk lid waarnaar u gaat versleutelen, niet alleen de peer die u uitnodigde. OnE2EEVerifyPeerIdentity wordt per lid afgevuurd, dus dezelfde pin-logica die u voor één-op-één-chats schreef geldt ongewijzigd, en met een lid waarvan u de sleutel weigert leidt u geen geheim af. RequireAuthentication geldt in een groep precies zoals in een direct gesprek.
Standaard achterwaarts compatibel
Identity.Enabled staat standaard op False. Met de optie uit wordt er geen identiteitssleutel verstuurd, geen handtekening geproduceerd, geen handtekening verwacht, en is de sleuteluitwisseling byte voor byte wat ze eerder was. Het upgraden van de bibliotheek verandert niets aan een bestaande E2EE-implementatie totdat u de property expliciet instelt.
De identiteitsvelden zijn additief op de lijn, dus een client met identiteit ingeschakeld praat nog steeds met een client zonder, zolang RequireAuthentication uit staat. Dat is wat u in staat stelt om het geleidelijk uit te rollen over een park dat u niet in één keer bijwerkt.
Beschikbaarheid
E2EE-identiteitsverificatie wordt geleverd in sgcWebSockets 2026.7 voor Delphi 7 tot en met 13 en C++Builder, op Win32/Win64, Linux64, macOS, Android en iOS. Het maakt deel uit van het E2EE-protocol, beschikbaar in de Enterprise- en All-Access-editie, en het werkt voor één-op-één-berichten en voor groepschats.
Klanten met een actief abonnement kunnen de nieuwe build downloaden in de klantenzone. Trialgebruikers kunnen de bijgewerkte installer ophalen op esegece.com/products/websockets/download.
Vragen, feedback of hulp bij het inbouwen van identiteitsverificatie in uw app? Neem contact op, u krijgt antwoord van de mensen die de code hebben geschreven.
