Verifica dell'identità E2EE: fermare uno scambio di chiavi nel mezzo | eSeGeCe Blog

Verifica dell'identità E2EE: fermare uno scambio di chiavi nel mezzo

· Componenti

Quando abbiamo introdotto la crittografia End-To-End in sgcWebSockets, la garanzia era chiara: Alice e Bob derivano un segreto condiviso con ECDH, cifrano con AES-GCM, e il server che instrada il traffico non vede altro che testo cifrato. Quella parte è solida. Ma c'è un passo precedente a tutto questo che la cifratura stessa non può proteggere, ed è il passo in cui avvengono gli attacchi reali: Alice deve ottenere la chiave pubblica di Bob da qualche parte, e la ottiene attraverso il server.

Immagina l'attacco in concreto. Alice chiede al server la chiave pubblica di Bob. Un server compromesso, un relay malevolo, o chiunque abbia preso il controllo di quel salto, non inoltra la chiave di Bob. Genera una propria coppia di chiavi e consegna ad Alice la propria chiave pubblica, etichettata "Bob". Poi fa lo stesso nella direzione opposta, consegnando a Bob un'altra chiave propria, etichettata "Alice". Alice ora cifra perfettamente, verso l'attaccante. L'attaccante decifra, legge, eventualmente riscrive, ricifra con la vera chiave di Bob e inoltra. Bob decifra perfettamente, dall'attaccante. Entrambe le parti vedono un lucchetto verde. Entrambe le parti si sbagliano. Questo è il classico scambio di chiavi man-in-the-middle, e nessuna quantità di AES-GCM lo risolve, perché entrambe le metà della conversazione sono cifrate in modo genuino e corretto. Sono solo cifrate verso la persona sbagliata.

Dalla versione 2026.7, sgcWebSockets chiude questa falla. Ogni endpoint può custodire una chiave di identità a lungo termine, firmare con essa la propria chiave di cifratura effimera, e il peer verifica quella firma prima ancora di derivare un segreto condiviso. La tua applicazione decide che cosa fare con l'impronta del peer: accettarla al primo utilizzo e fissarla, confrontarla fuori banda, oppure rifiutare qualsiasi cosa non sia firmata. È disattivata per impostazione predefinita e il formato sulla rete resta invariato finché non la attivi.

Cifratura non significa autenticazione

Sono due proprietà diverse e vale la pena essere netti su quale delle due hai davvero.

La cifratura risponde alla domanda "una terza parte può leggere questo?". ECDH più AES-GCM risponde: no. L'autenticazione risponde alla domanda "la chiave verso cui ho cifrato è davvero la chiave della persona con cui credo di parlare?". ECDH da solo non risponde affatto a questa domanda. Concorderà volentieri un segreto condiviso con chiunque abbia inviato la chiave, e non ha alcuna opinione su chi fosse.

Tutto ciò che c'è in questo articolo riguarda la seconda domanda. Nulla di quanto segue cambia la suite di cifratura, la derivazione delle chiavi o il formato dei messaggi. Aggiunge una firma sulla chiave effimera, e un punto in cui la tua applicazione può dire sì o no al peer che c'è dietro.

Chiavi di identità a lungo termine

Ora ci sono due tipi di chiave in una sessione E2EE, e tenerle distinte è tutto il punto della questione.

La chiave di cifratura effimera è la coppia di chiavi ECDH che il client già usava. Esiste per la durata della sessione, è quella da cui viene derivato il segreto condiviso e può essere rigenerata liberamente. Non dice nulla su chi sei.

La chiave di identità è una coppia di chiavi ECDSA P-256 a lungo termine. La generi una sola volta, conservi la metà privata sul dispositivo e la mantieni attraverso riavvii, riconnessioni e nuove sessioni. Il suo unico compito è firmare la chiave effimera: "la chiave pubblica effimera che hai appena ricevuto è stata davvero pubblicata dal detentore di questa chiave di identità". Il peer verifica quella firma con la chiave pubblica di identità che l'accompagnava, e il server, che può comunque vedere e inoltrare entrambe, non può falsificare la firma perché non possiede la chiave privata di identità.

Questo riduce il problema del man-in-the-middle a una sola domanda: questa chiave pubblica di identità è davvero quella del mio peer? È una domanda a cui la tua applicazione può rispondere, perché, a differenza di una chiave effimera casuale, una chiave di identità è stabile, quindi può essere ricordata, fissata e confrontata da una persona.

Generare e conservare una chiave di identità

Una funzione di supporto in sgcSSL_E2EE crea la coppia di chiavi come stringhe PEM. Chiamala una sola volta per installazione, per utente, e conserva la chiave privata dove la tua applicazione conserva i segreti. Non inviare mai la chiave privata da nessuna parte.

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 stessa coppia di chiavi può essere generata anche dal client E2EE stesso con GenerateIdentityKeyPair, se preferisci tenerla sul componente:

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

La conservazione è una tua decisione, ed è la parte che merita più attenzione. La chiave privata è ciò che dimostra che sei tu. Mettila nel keystore della piattaforma, in un blob protetto da DPAPI, in un file di impostazioni cifrato, ovunque il tuo modello di minaccia dica che debba stare. Se trapela, un attaccante può impersonare quell'identità, e l'impronta che i tuoi utenti hanno confrontato continuerà a corrispondere.

Come attivarla

La firma di identità vive sotto E2EE_Options.Identity. Carica la coppia di chiavi conservata, imposta Enabled, e da quel momento il client firma la propria chiave effimera e verifica le firme che riceve.

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;

Da qui in poi la chiave pubblica effimera che il client pubblica è accompagnata dalla chiave pubblica di identità e da una firma su quella chiave effimera. Quando arriva la chiave di un peer, la firma viene verificata rispetto alla chiave di identità che l'accompagna. Se la firma non risulta valida, la chiave del peer viene rifiutata e da essa non viene derivato alcun segreto condiviso.

Nota bene che cosa dimostra e che cosa non dimostra una firma valida. Dimostra che la chiave effimera è stata firmata dal detentore di quella chiave privata di identità. Non dimostra, di per sé, che quella chiave di identità appartenga al tuo peer, perché un man in the middle può presentare la propria chiave di identità con la propria firma perfettamente valida. È a quell'ultimo miglio che servono le due sezioni successive.

Verificare un peer: fiducia al primo utilizzo e pinning

Una volta che una firma è verificata, OnE2EEVerifyPeerIdentity viene scatenato con l'id utente del peer, la chiave pubblica di identità e la sua impronta. Il parametro aAccept è un var e arriva come True, quindi se non fai nulla il peer viene accettato. La decisione è deliberatamente tua, perché solo la tua applicazione sa se ha già visto questo contatto in precedenza.

Lo schema standard è la fiducia al primo utilizzo più il pinning. La prima volta che vedi un utente, registri l'impronta. Ogni volta successiva, la confronti. Se corrisponde, accetti in silenzio. Se non corrisponde, non accetti, e lo comunichi all'utente.

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 fiducia al primo utilizzo ha un limite onesto che vale la pena dichiarare: se l'attaccante era già nel mezzo proprio in quel primo contatto, tu fissi l'attaccante. Quello che ti garantisce, però, è che l'attaccante deve essere lì fin dall'inizio e restarci per sempre, e che qualsiasi tentativo successivo di scambiare la chiave fa rumore. Se devi chiudere anche la falla del primo contatto, confronti l'impronta fuori banda, ed è l'argomento della sezione successiva.

Impronte che i tuoi utenti possono davvero confrontare

Un'impronta è un digest della chiave pubblica di identità, prodotto da sgcE2EE_IdentityFingerprint. Due endpoint che detengono la stessa chiave di identità producono la stessa impronta, e un man in the middle che detiene una chiave diversa non può produrne una corrispondente.

È esattamente il meccanismo dietro il "safety number" di Signal e il "codice di sicurezza" di WhatsApp. Il suo valore sta nel fatto che è confrontabile da una persona su un canale che l'attaccante non controlla. Alice lo legge al telefono a Bob, oppure si mostrano a vicenda un codice QR di persona, oppure lo incollano in una chat già considerata fidata. Se i due valori corrispondono, non c'è nessuno nel mezzo. Se differiscono, c'è.

Presentala ai tuoi utenti in una forma che possano davvero leggere ad alta voce. Raggruppare il digest in blocchi brevi è sufficiente:

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;

Mostra la tua impronta nell'applicazione, mostra l'impronta del peer accanto al contatto, e lascia che l'utente marchi un contatto come verificato una volta che ha confrontato le due. È quello il momento in cui la cifratura significa davvero ciò che i tuoi utenti pensano che significhi.

Quando la chiave di identità di un peer cambia

OnE2EEKeyChange viene scatenato quando un utente presenta una chiave di identità diversa dall'ultima che avevi visto per lui, e ti consegna sia la vecchia sia la nuova impronta.

Fai attenzione a come lo presenti. Una chiave di identità cambiata non è automaticamente un attacco. Un utente che ha reinstallato l'app, che ha resettato un dispositivo o che ha effettuato l'accesso da un nuovo telefono avrà legittimamente una nuova chiave di identità, e questa è di gran lunga la causa più comune. È però esattamente il segnale che produrrebbe uno scambio di chiavi, quindi è il momento in cui l'utente va avvisato, ed è il momento in cui la tua applicazione potrebbe voler rimuovere il pin e chiedere un nuovo confronto fuori banda.

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;

Abbinalo al gestore visto sopra: OnE2EEKeyChange ti dice che la chiave è cambiata, OnE2EEVerifyPeerIdentity è il punto in cui decidi se continuare a parlare.

Rifiutare in caso di dubbio con RequireAuthentication

Con Identity.Enabled impostato e RequireAuthentication lasciato al suo valore predefinito False, il client è in modalità best-effort. I peer che presentano una firma valida vengono verificati. I peer che non presentano alcuna firma, un client più vecchio, un client che non ha abilitato l'identità, vengono comunque accettati. Questo è comodo durante un rollout, quando non tutti gli endpoint sono stati aggiornati, ma non è un confine di sicurezza: un attaccante può semplicemente rimuovere la firma e passare per un client vecchio.

RequireAuthentication := True è l'interruttore che fa rifiutare in caso di dubbio. Una chiave di peer non firmata, o la cui firma non risulta valida, viene rifiutata invece che accettata.

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

Distribuiscilo in due passi. Rilascia le chiavi di identità con RequireAuthentication disattivato, aspetta che tutto il tuo parco le abbia, poi attivalo. Una volta attivo, un attacco di downgrade che rimuove la firma non funziona più, perché una firma mancante diventa un rifiuto invece che una scrollata di spalle.

Chat di gruppo

La messaggistica di gruppo riceve lo stesso trattamento. Ogni membro firma la propria chiave effimera con la propria chiave di identità, e la chiave pubblica di identità e la firma viaggiano insieme alla voce di ciascun membro, quindi entrare in un gruppo significa verificare ogni membro verso cui stai per cifrare, non solo il peer che ti ha invitato. OnE2EEVerifyPeerIdentity viene scatenato per ogni membro, quindi la stessa logica di pinning che hai scritto per le chat uno a uno si applica senza modifiche, e un membro la cui chiave rifiuti non è un membro con cui derivi un segreto. RequireAuthentication si applica in un gruppo esattamente come in una conversazione diretta.

Compatibile con il passato per impostazione predefinita

Identity.Enabled è False di serie. Con l'opzione disattivata, non viene inviata alcuna chiave di identità, non viene prodotta alcuna firma, nessuna viene attesa, e lo scambio di chiavi è byte per byte quello di prima. Aggiornare la libreria non cambia nulla in un deployment E2EE esistente finché non imposti esplicitamente la proprietà.

I campi di identità sono additivi sulla rete, quindi un client con l'identità abilitata parla comunque con un client che non ce l'ha, purché RequireAuthentication sia disattivato. È questo che ti permette di distribuirla gradualmente su un parco che non aggiorni tutto in una volta.

Disponibilità

La verifica dell'identità E2EE è inclusa in sgcWebSockets 2026.7 per Delphi da 7 a 13 e C++Builder, su Win32/Win64, Linux64, macOS, Android e iOS. Fa parte del protocollo E2EE, disponibile nelle edizioni Enterprise e All-Access, e funziona per i messaggi uno a uno e per le chat di gruppo.

I clienti con un abbonamento attivo possono scaricare la nuova build dall'area clienti. Gli utenti trial possono ottenere l'installer aggiornato su esegece.com/products/websockets/download.

Domande, commenti o bisogno di aiuto per integrare la verifica dell'identità nella tua app? Contattaci, riceverai una risposta dalle persone che hanno scritto il codice.