Lorsque nous avons présenté le chiffrement de bout en bout dans sgcWebSockets, la garantie était claire : Alice et Bob dérivent un secret partagé avec ECDH, chiffrent avec AES-GCM, et le serveur qui achemine le trafic ne voit rien d'autre que du texte chiffré. Cette partie-là est solide. Mais il y a une étape en amont de tout cela que le chiffrement lui-même ne peut pas protéger, et c'est précisément là que se produisent les vraies attaques : Alice doit obtenir la clé publique de Bob quelque part, et elle l'obtient via le serveur.
Imaginez l'attaque concrètement. Alice demande au serveur la clé publique de Bob. Un serveur compromis, un relais malveillant, ou quiconque a pris le contrôle de ce saut, ne transmet pas la clé de Bob. Il génère sa propre paire de clés et remet à Alice sa clé publique, étiquetée « Bob ». Puis il fait la même chose dans l'autre sens, en remettant à Bob une autre de ses clés, étiquetée « Alice ». Alice chiffre désormais parfaitement, vers l'attaquant. L'attaquant déchiffre, lit, réécrit éventuellement, rechiffre vers la vraie clé de Bob, et transmet. Bob déchiffre parfaitement, depuis l'attaquant. Les deux parties voient un cadenas vert. Les deux parties se trompent. C'est la classique attaque de l'homme du milieu par substitution de clé, et aucune quantité d'AES-GCM ne la corrige, parce que les deux moitiés de la conversation sont réellement et correctement chiffrées. Elles sont simplement chiffrées vers la mauvaise personne.
À partir de la version 2026.7, sgcWebSockets comble cette faille. Chaque point de terminaison peut détenir une clé d'identité à long terme, signer avec elle sa clé de chiffrement éphémère, et le pair vérifie cette signature avant même de dériver un secret partagé. Votre application décide de ce qu'elle fait de l'empreinte du pair : l'accepter à la première utilisation et l'épingler, la comparer hors bande, ou refuser tout ce qui n'est pas signé. C'est désactivé par défaut et le format sur le fil reste inchangé tant que vous ne l'activez pas.
Le chiffrement n'est pas l'authentification
Ce sont deux propriétés différentes et il vaut la peine d'être direct sur celle que vous possédez.
Le chiffrement répond à la question « un tiers peut-il lire ceci ? ». ECDH plus AES-GCM y répond : non. L'authentification répond à la question « la clé vers laquelle j'ai chiffré est-elle vraiment celle de la personne à qui je crois parler ? ». ECDH seul n'y répond pas du tout. Il se mettra volontiers d'accord sur un secret partagé avec quiconque a envoyé la clé, et il n'a aucun avis sur l'identité de cette personne.
Tout ce billet porte sur la seconde question. Rien ici ne change la suite cryptographique, la dérivation de clés ou le format des messages. Cela ajoute une signature sur la clé éphémère, et un endroit où votre application peut dire oui ou non au pair qui se trouve derrière.
Clés d'identité à long terme
Il y a désormais deux sortes de clés dans une session E2EE, et bien les distinguer est toute l'idée.
La clé de chiffrement éphémère est la paire de clés ECDH que le client utilisait déjà. Elle existe le temps de la session, c'est d'elle qu'est dérivé le secret partagé, et elle peut être régénérée librement. Elle ne dit rien de votre identité.
La clé d'identité est une paire de clés ECDSA P-256 à long terme. Vous la générez une seule fois, vous conservez la moitié privée sur l'appareil, et vous la gardez d'un redémarrage à l'autre, d'une reconnexion à l'autre, d'une session à l'autre. Son unique rôle est de signer la clé éphémère : « la clé publique éphémère que vous venez de recevoir a bien été publiée par le détenteur de cette clé d'identité ». Le pair vérifie cette signature avec la clé publique d'identité qui l'accompagnait, et le serveur, qui peut toujours voir et relayer les deux, ne peut pas falsifier la signature puisqu'il ne possède pas la clé privée d'identité.
Cela réduit le problème de l'homme du milieu à une seule question : cette clé publique d'identité est-elle bien celle de mon pair ? C'est une question à laquelle votre application peut répondre car, contrairement à une clé éphémère aléatoire, une clé d'identité est stable, donc elle peut être mémorisée, épinglée et comparée par un humain.
Générer et stocker une clé d'identité
Une fonction utilitaire de sgcSSL_E2EE crée la paire de clés sous forme de chaînes PEM. Appelez-la une fois par installation, par utilisateur, et stockez la clé privée là où votre application stocke ses secrets. N'envoyez jamais la clé privée nulle part.
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;
La même paire de clés peut aussi être générée depuis le client E2EE lui-même avec GenerateIdentityKeyPair, si vous préférez la garder sur le composant :
var
vPrivateKeyPEM, vPublicKeyPEM: string;
begin
oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;
Le stockage vous appartient, et c'est la partie qui mérite le plus d'attention. La clé privée est ce qui prouve que vous êtes bien vous. Placez-la dans le magasin de clés de la plateforme, dans un blob protégé par DPAPI, dans un fichier de configuration chiffré, là où votre modèle de menace dit qu'elle doit être. Si elle fuit, un attaquant peut usurper cette identité, et l'empreinte que vos utilisateurs ont comparée correspondra toujours.
Activer la fonctionnalité
La signature d'identité se trouve sous E2EE_Options.Identity. Chargez la paire de clés stockée, activez Enabled, et à partir de là le client signe sa clé éphémère et vérifie les signatures qu'il reçoit.
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;
Dès lors, la clé publique éphémère que le client publie est accompagnée de la clé publique d'identité et d'une signature portant sur cette clé éphémère. Quand la clé d'un pair arrive, la signature est vérifiée avec la clé d'identité qui l'accompagne. Si la signature n'est pas valide, la clé du pair est rejetée et aucun secret partagé n'en est dérivé.
Notez bien ce qu'une signature valide prouve et ne prouve pas. Elle prouve que la clé éphémère a été signée par le détenteur de cette clé privée d'identité. Elle ne prouve pas à elle seule que cette clé d'identité appartient à votre pair, car un homme du milieu peut présenter sa propre clé d'identité avec sa propre signature parfaitement valide. Ce dernier kilomètre est l'objet des deux sections suivantes.
Vérifier un pair : confiance à la première utilisation et épinglage
Une fois la signature vérifiée, OnE2EEVerifyPeerIdentity se déclenche avec l'identifiant utilisateur du pair, la clé publique d'identité et son empreinte. Le paramètre aAccept est un var, et il arrive à True, donc si vous ne faites rien le pair est accepté. La décision vous revient délibérément, car seule votre application sait si elle a déjà vu ce contact.
Le schéma habituel est la confiance à la première utilisation, complétée par l'épinglage. La première fois que vous voyez un utilisateur, enregistrez son empreinte. Toutes les fois suivantes, comparez. Si elle correspond, acceptez sans rien dire. Si elle ne correspond pas, n'acceptez pas, et prévenez l'utilisateur.
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;
La confiance à la première utilisation a une limite qu'il faut énoncer honnêtement : si l'attaquant était déjà au milieu lors de ce tout premier contact, c'est lui que vous épinglez. Ce qu'elle vous apporte, c'est que l'attaquant doit être présent dès le début et le rester pour toujours, et que toute tentative ultérieure de substituer la clé fait du bruit. Si vous devez aussi refermer la faille du premier contact, il faut comparer l'empreinte hors bande, ce qui est l'objet de la section suivante.
Des empreintes que vos utilisateurs peuvent réellement comparer
Une empreinte est un condensé de la clé publique d'identité, produit par sgcE2EE_IdentityFingerprint. Deux points de terminaison détenant la même clé d'identité produisent la même empreinte, et un homme du milieu détenant une clé différente ne peut pas en produire une qui corresponde.
C'est exactement le mécanisme derrière le « numéro de sécurité » de Signal et le « code de sécurité » de WhatsApp. Son intérêt est qu'il est comparable par un humain, sur un canal que l'attaquant ne contrôle pas. Alice le lit à Bob au téléphone, ou bien ils se montrent un code QR en personne, ou encore ils le collent dans une conversation de confiance déjà établie. Si les deux valeurs correspondent, il n'y a personne au milieu. Si elles diffèrent, il y a quelqu'un.
Présentez-la à vos utilisateurs sous une forme qu'ils peuvent vraiment lire à voix haute. Regrouper le condensé en petits blocs suffit :
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;
Affichez votre propre empreinte dans l'application, affichez celle du pair à côté du contact, et laissez l'utilisateur marquer un contact comme vérifié une fois qu'il a comparé les deux. C'est à ce moment-là que le chiffrement signifie vraiment ce que vos utilisateurs croient qu'il signifie.
Quand la clé d'identité d'un pair change
OnE2EEKeyChange se déclenche lorsqu'un utilisateur présente une clé d'identité différente de celle que vous aviez vue en dernier pour lui, et il vous fournit à la fois l'ancienne et la nouvelle empreinte.
Soyez prudent dans la manière de le présenter. Une clé d'identité qui change n'est pas automatiquement une attaque. Un utilisateur qui a réinstallé l'application, effacé un appareil ou s'est connecté sur un nouveau téléphone aura légitimement une nouvelle clé d'identité, et c'est de loin la cause la plus fréquente. C'est toutefois exactement le signal que produirait une substitution de clé, c'est donc le moment de prévenir l'utilisateur, et le moment où votre application peut vouloir abandonner l'épinglage et demander une nouvelle comparaison hors bande.
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;
Associez-le au gestionnaire précédent : OnE2EEKeyChange vous dit que la clé a changé, OnE2EEVerifyPeerIdentity est l'endroit où vous décidez si vous continuez à parler.
Refuser par défaut avec RequireAuthentication
Avec Identity.Enabled activé et RequireAuthentication laissé à sa valeur par défaut False, le client est en mode « au mieux ». Les pairs qui présentent une signature valide sont vérifiés. Les pairs qui ne présentent aucune signature, un client plus ancien, un client qui n'a pas activé l'identité, sont malgré tout acceptés. C'est pratique pendant un déploiement progressif, quand tous les points de terminaison n'ont pas encore été mis à jour, mais ce n'est pas une frontière de sécurité : un attaquant peut simplement retirer la signature et se faire passer pour un ancien client.
RequireAuthentication := True est l'interrupteur du refus par défaut. Une clé de pair non signée, ou dont la signature ne se vérifie pas, est refusée au lieu d'être acceptée.
// ... 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;
Déployez-le en deux temps. Livrez les clés d'identité avec RequireAuthentication désactivé, attendez que tout votre parc les possède, puis activez-le. Une fois activé, une attaque par rétrogradation qui supprime la signature ne fonctionne plus, car une signature manquante devient un rejet au lieu d'un haussement d'épaules.
Discussions de groupe
La messagerie de groupe bénéficie du même traitement. Chaque membre signe sa clé éphémère avec sa propre clé d'identité, et la clé publique d'identité ainsi que la signature voyagent avec l'entrée de chaque membre, si bien que rejoindre un groupe vérifie chacun des membres vers lesquels vous allez chiffrer, pas seulement le pair qui vous a invité. OnE2EEVerifyPeerIdentity se déclenche pour chaque membre, donc la même logique d'épinglage que vous avez écrite pour les conversations en tête-à-tête s'applique sans changement, et un membre dont vous refusez la clé n'est pas un membre avec lequel vous dérivez un secret. RequireAuthentication s'applique dans un groupe exactement comme dans une conversation directe.
Compatible avec l'existant par défaut
Identity.Enabled vaut False à la sortie de la boîte. Désactivé, aucune clé d'identité n'est envoyée, aucune signature n'est produite, aucune n'est attendue, et l'échange de clés est octet pour octet ce qu'il était avant. Mettre à jour la bibliothèque ne change rien à un déploiement E2EE existant tant que vous ne définissez pas explicitement la propriété.
Les champs d'identité sont additifs sur le fil, donc un client avec l'identité activée parle toujours à un client sans elle, tant que RequireAuthentication est désactivé. C'est ce qui vous permet de le déployer progressivement sur un parc que vous ne mettez pas à jour d'un seul coup.
Disponibilité
La vérification d'identité E2EE est livrée dans sgcWebSockets 2026.7 pour Delphi 7 à 13 et C++Builder, sur Win32/Win64, Linux64, macOS, Android et iOS. Elle fait partie du protocole E2EE, disponible dans les éditions Enterprise et All-Access, et elle fonctionne aussi bien pour les messages en tête-à-tête que pour les discussions de groupe.
Les clients disposant d'un abonnement actif peuvent télécharger la nouvelle version depuis l'espace client. Les utilisateurs en version d'essai peuvent récupérer le programme d'installation mis à jour sur esegece.com/products/websockets/download.
Des questions, des remarques ou besoin d'aide pour intégrer la vérification d'identité dans votre application ? Contactez-nous, vous recevrez une réponse des personnes qui ont écrit le code.
