E2EE Identity Verification: Stopping a Key Swap in the Middle | eSeGeCe Blog

E2EE Identity Verification: Stopping a Key Swap in the Middle

· Components
E2EE Identity Verification: Stopping a Key Swap in the Middle

When we introduced End-To-End Encryption in sgcWebSockets, the guarantee was clear: Alice and Bob derive a shared secret with ECDH, encrypt with AES-GCM, and the server that routes the traffic sees nothing but ciphertext. That part is solid. But there is a step before all of it that the encryption itself cannot protect, and it is the step where real attacks happen: Alice has to get Bob's public key from somewhere, and she gets it through the server.

Picture the attack concretely. Alice asks the server for Bob's public key. A compromised server, a rogue relay, or anyone who has taken over that hop, does not forward Bob's key. It generates a key pair of its own and hands Alice its public key, labelled "Bob". Then it does the same in the other direction, handing Bob a different key of its own, labelled "Alice". Alice now encrypts perfectly, to the attacker. The attacker decrypts, reads, possibly rewrites, re-encrypts to Bob's real key, and forwards. Bob decrypts perfectly, from the attacker. Both sides see a green padlock. Both sides are wrong. This is the classic man-in-the-middle key swap, and no amount of AES-GCM fixes it, because both halves of the conversation are genuinely, correctly encrypted. They are just encrypted to the wrong person.

From version 2026.7, sgcWebSockets closes this gap. Each endpoint can hold a long-term identity key, sign its ephemeral encryption key with it, and the peer verifies that signature before it ever derives a shared secret. Your application decides what to do with the peer's fingerprint: accept it on first use and pin it, compare it out of band, or refuse anything that is not signed at all. It is off by default and the wire format is unchanged until you turn it on.

Encryption is not authentication

These are two different properties and it is worth being blunt about which one you have.

Encryption answers "can a third party read this?". ECDH plus AES-GCM answers that: no. Authentication answers "is the key I encrypted to really the key of the person I think I am talking to?". Plain ECDH does not answer that at all. It will happily agree on a shared secret with whoever sent the key, and it has no opinion about who that was.

Everything in this post is about the second question. Nothing here changes the cipher suite, the key derivation, or the message format. It adds a signature over the ephemeral key, and a place for your application to say yes or no to the peer behind it.

Long-term identity keys

There are now two kinds of key in an E2EE session, and keeping them straight is the whole idea.

The ephemeral encryption key is the ECDH key pair the client already used. It exists for the session, it is what the shared secret is derived from, and it can be regenerated freely. It says nothing about who you are.

The identity key is a long-term ECDSA P-256 key pair. You generate it once, you persist the private half on the device, and you keep it across restarts, reconnects and new sessions. Its only job is to sign the ephemeral key: "the ephemeral public key you just received really was published by the holder of this identity key". The peer verifies that signature with the identity public key that travelled alongside it, and the server, which can still see and relay both, cannot forge the signature because it does not have the identity private key.

That reduces the man-in-the-middle problem to one question, and only one: is this identity public key the one that really belongs to my peer? That is a question your application can answer, because unlike a random ephemeral key, an identity key is stable, so it can be remembered, pinned, and compared by a human.

Generating and storing an identity key

A helper in sgcSSL_E2EE creates the key pair as PEM strings. Call it once per install, per user, and store the private key wherever your application stores secrets. Never send the private key anywhere.

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;

The same key pair can also be generated from the E2EE client itself with GenerateIdentityKeyPair, if you prefer to keep it on the component:

var
  vPrivateKeyPEM, vPublicKeyPEM: string;
begin
  oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;

Storage is your decision, and it is the part that deserves care. The private key is the thing that proves you are you. Put it in the platform keystore, in a DPAPI protected blob, in an encrypted settings file, wherever your threat model says it belongs. If it leaks, an attacker can impersonate that identity, and the fingerprint your users compared will still match.

Turning it on

Identity signing lives under E2EE_Options.Identity. Load the stored key pair, set Enabled, and the client signs its ephemeral key from that point on and verifies the signatures it receives.

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;

From here the ephemeral public key the client publishes is accompanied by the identity public key and a signature over that ephemeral key. When a peer's key arrives, the signature is checked against the identity key that came with it. If the signature does not verify, the peer's key is rejected and no shared secret is derived from it.

Note carefully what a valid signature does and does not prove. It proves the ephemeral key was signed by the holder of that identity private key. It does not, by itself, prove that identity key belongs to your peer, because a man in the middle can present its own identity key with its own perfectly valid signature. That last mile is what the next two sections are for.

Verifying a peer: trust on first use and pinning

Once a signature verifies, OnE2EEVerifyPeerIdentity fires with the peer's user id, the identity public key, and its fingerprint. The aAccept parameter is a var, and it arrives as True, so if you do nothing the peer is accepted. The decision is deliberately yours, because only your application knows whether it has seen this contact before.

The standard pattern is trust on first use plus pinning. The first time you see a user, record the fingerprint. Every time after, compare. If it matches, accept silently. If it does not, do not accept, and tell the user.

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;

Trust on first use has an honest limitation worth stating: if the attacker was already in the middle on that very first contact, you pin the attacker. What it does buy you is that the attacker must be there from the beginning and stay there forever, and that any later attempt to swap the key is loud. If you need to close the first-contact hole too, you compare the fingerprint out of band, which is the next section.

Fingerprints your users can actually compare

A fingerprint is a digest of the identity public key, produced by sgcE2EE_IdentityFingerprint. Two endpoints holding the same identity key produce the same fingerprint, and a man in the middle holding a different key cannot produce a matching one.

This is exactly the mechanism behind Signal's "safety number" and WhatsApp's "security code". The value of it is that it is comparable by a human over a channel the attacker does not control. Alice reads it out on the phone to Bob, or they show each other a QR code in person, or they paste it into an existing trusted chat. If the two values match, there is nobody in the middle. If they differ, there is.

Give it to your users in a form they can actually read out loud. Grouping the digest into short blocks is enough:

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;

Show your own fingerprint in the application, show the peer's fingerprint next to the contact, and let the user mark a contact as verified once they have compared the two. That is the point at which the encryption actually means what your users think it means.

When a peer's identity key changes

OnE2EEKeyChange fires when a user presents an identity key different from the one you last saw for them, and it hands you both the old and the new fingerprint.

Be careful about how you present this. A changed identity key is not automatically an attack. A user who reinstalled the app, wiped a device, or signed in on a new phone will legitimately have a new identity key, and this is the single most common cause. It is, however, the exact signal a key swap would produce, so it is the moment the user should be told, and the moment your application may want to drop the pin and ask for a fresh out-of-band comparison.

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;

Pair it with the handler above: OnE2EEKeyChange tells you the key moved, OnE2EEVerifyPeerIdentity is where you decide whether to keep talking.

Fail closed with RequireAuthentication

With Identity.Enabled set and RequireAuthentication left at its default of False, the client is in a best-effort mode. Peers that present a valid signature are verified. Peers that present no signature at all, an older client, a client that has not enabled identity, are still accepted. That is convenient during a rollout, when not every endpoint has been updated, but it is not a security boundary: an attacker can simply strip the signature and look like an old client.

RequireAuthentication := True is the fail-closed switch. An unsigned peer key, or one whose signature does not verify, is refused instead of accepted.

// ... 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;

Roll it out in two steps. Ship identity keys with RequireAuthentication off, wait until your fleet has them, then turn it on. Once it is on, a downgrade attack that removes the signature no longer works, because a missing signature is a rejection instead of a shrug.

Group chats

Group messaging gets the same treatment. Every member signs its ephemeral key with its own identity key, and the identity public key and signature travel with each member's entry, so joining a group verifies every member you are about to encrypt to, not just the peer who invited you. OnE2EEVerifyPeerIdentity fires per member, so the same pinning logic you wrote for one-to-one chats applies unchanged, and a member whose key you refuse is not one you derive a secret with. RequireAuthentication applies in a group exactly as it does in a direct conversation.

Backward compatible by default

Identity.Enabled is False out of the box. With it off, no identity key is sent, no signature is produced, none is expected, and the key exchange is byte-for-byte what it was before. Upgrading the library changes nothing about an existing E2EE deployment until you explicitly set the property.

The identity fields are additive on the wire, so a client with identity enabled still talks to a client without it, as long as RequireAuthentication is off. That is what lets you deploy it gradually across a fleet you do not update all at once.

Availability

E2EE identity verification ships in sgcWebSockets 2026.7 for Delphi 7 through 13 and C++Builder, on Win32/Win64, Linux64, macOS, Android and iOS. It is part of the E2EE protocol, available in the Enterprise and All-Access editions, and it works for one-to-one messages and for group chats.

Customers with an active subscription can download the new build from the customer area. Trial users can grab the updated installer at esegece.com/products/websockets/download.

Questions, feedback or help wiring identity verification into your app? Get in touch, you will get a reply from the people who wrote the code.