Verificación de identidad en E2EE: cómo frenar un cambio de clave por el camino | Blog de eSeGeCe

Verificación de identidad en E2EE: cómo frenar un cambio de clave por el camino

· Componentes

Cuando presentamos el cifrado de extremo a extremo en sgcWebSockets, la garantía estaba clara: Alicia y Bruno derivan un secreto compartido con ECDH, cifran con AES-GCM, y el servidor que encamina el tráfico no ve más que texto cifrado. Esa parte es sólida. Pero hay un paso previo a todo ello que el propio cifrado no puede proteger, y es el paso donde ocurren los ataques reales: Alicia tiene que obtener la clave pública de Bruno de algún sitio, y la obtiene a través del servidor.

Imagina el ataque en concreto. Alicia le pide al servidor la clave pública de Bruno. Un servidor comprometido, un repetidor malicioso o cualquiera que se haya apoderado de ese salto no reenvía la clave de Bruno. Genera un par de claves propio y le entrega a Alicia su clave pública, etiquetada como "Bruno". Después hace lo mismo en el otro sentido, y le entrega a Bruno otra clave propia distinta, etiquetada como "Alicia". Alicia cifra ahora a la perfección, pero para el atacante. El atacante descifra, lee, quizá reescribe, vuelve a cifrar con la clave real de Bruno y reenvía. Bruno descifra a la perfección, pero desde el atacante. Ambos lados ven un candado verde. Ambos lados se equivocan. Este es el clásico cambio de clave con un intermediario, y ninguna cantidad de AES-GCM lo arregla, porque las dos mitades de la conversación están genuina y correctamente cifradas. Solo que están cifradas para la persona equivocada.

A partir de la versión 2026.7, sgcWebSockets cierra este hueco. Cada extremo puede mantener una clave de identidad de larga duración, firmar con ella su clave efímera de cifrado, y el par verifica esa firma antes siquiera de derivar un secreto compartido. Tu aplicación decide qué hacer con la huella del par: aceptarla en el primer uso y fijarla, compararla por un canal alternativo, o rechazar cualquier cosa que no venga firmada. Está desactivada por defecto y el formato en el cable no cambia hasta que la habilitas.

El cifrado no es autenticación

Son dos propiedades distintas y merece la pena ser tajante sobre cuál de ellas tienes.

El cifrado responde a "¿puede leer esto un tercero?". ECDH más AES-GCM responde: no. La autenticación responde a "¿la clave para la que he cifrado es realmente la clave de la persona con la que creo que estoy hablando?". ECDH a secas no responde a eso en absoluto. Se pondrá de acuerdo tan tranquilo sobre un secreto compartido con quienquiera que haya enviado la clave, y no tiene ninguna opinión sobre quién era.

Todo lo que hay en este artículo va sobre la segunda pregunta. Nada de esto cambia el conjunto de cifradores, la derivación de claves ni el formato de los mensajes. Añade una firma sobre la clave efímera, y un sitio donde tu aplicación puede decir sí o no al par que hay detrás.

Claves de identidad de larga duración

Ahora hay dos tipos de clave en una sesión E2EE, y no confundirlas es toda la idea.

La clave efímera de cifrado es el par de claves ECDH que el cliente ya usaba. Existe durante la sesión, es de donde se deriva el secreto compartido, y se puede regenerar libremente. No dice nada sobre quién eres.

La clave de identidad es un par de claves ECDSA P-256 de larga duración. Lo generas una vez, guardas la mitad privada en el dispositivo, y la conservas entre reinicios, reconexiones y nuevas sesiones. Su única misión es firmar la clave efímera: "la clave pública efímera que acabas de recibir la publicó realmente quien posee esta clave de identidad". El par verifica esa firma con la clave pública de identidad que viajaba junto a ella y el servidor, que sigue pudiendo ver y retransmitir ambas, no puede falsificar la firma porque no tiene la clave privada de identidad.

Eso reduce el problema del intermediario a una sola pregunta, y solo una: ¿es esta clave pública de identidad la que de verdad pertenece a mi par? Esa es una pregunta que tu aplicación puede responder porque, a diferencia de una clave efímera aleatoria, una clave de identidad es estable, así que se puede recordar, fijar y comparar por un humano.

Generar y guardar una clave de identidad

Una función auxiliar de sgcSSL_E2EE crea el par de claves como cadenas PEM. Llámala una vez por instalación y por usuario, y guarda la clave privada donde tu aplicación guarde los secretos. No envíes nunca la clave privada a ninguna 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;

El mismo par de claves también se puede generar desde el propio cliente E2EE con GenerateIdentityKeyPair, si prefieres mantenerlo en el componente:

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

El almacenamiento es decisión tuya, y es la parte que merece cuidado. La clave privada es lo que demuestra que eres tú. Ponla en el almacén de claves de la plataforma, en un blob protegido con DPAPI, en un archivo de configuración cifrado, donde tu modelo de amenazas diga que corresponde. Si se filtra, un atacante puede suplantar esa identidad, y la huella que compararon tus usuarios seguirá coincidiendo.

Cómo activarla

La firma de identidad vive bajo E2EE_Options.Identity. Carga el par de claves guardado, establece Enabled, y a partir de ese momento el cliente firma su clave efímera y verifica las firmas que recibe.

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;

A partir de aquí, la clave pública efímera que publica el cliente va acompañada de la clave pública de identidad y de una firma sobre esa clave efímera. Cuando llega la clave de un par, la firma se comprueba contra la clave de identidad que venía con ella. Si la firma no verifica, la clave del par se rechaza y no se deriva de ella ningún secreto compartido.

Fíjate bien en lo que una firma válida demuestra y en lo que no. Demuestra que la clave efímera la firmó quien posee esa clave privada de identidad. No demuestra, por sí sola, que esa clave de identidad pertenezca a tu par, porque un intermediario puede presentar su propia clave de identidad con su propia firma perfectamente válida. Ese último tramo es de lo que se ocupan las dos secciones siguientes.

Verificar a un par: confianza en el primer uso y fijación

Una vez que una firma verifica, OnE2EEVerifyPeerIdentity se dispara con el identificador de usuario del par, la clave pública de identidad y su huella. El parámetro aAccept es un var, y llega con el valor True, así que si no haces nada el par se acepta. La decisión es deliberadamente tuya, porque solo tu aplicación sabe si ya ha visto antes a este contacto.

El patrón habitual es confianza en el primer uso más fijación. La primera vez que ves a un usuario, registra la huella. Todas las veces siguientes, compara. Si coincide, acepta en silencio. Si no coincide, no aceptes, y avisa al usuario.

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 confianza en el primer uso tiene una limitación honesta que conviene decir: si el atacante ya estaba en medio en ese primerísimo contacto, fijas al atacante. Lo que sí te aporta es que el atacante tiene que estar ahí desde el principio y quedarse para siempre, y que cualquier intento posterior de cambiar la clave se oye a gritos. Si además necesitas cerrar el agujero del primer contacto, comparas la huella por un canal alternativo, que es lo que viene en la siguiente sección.

Huellas que tus usuarios pueden comparar de verdad

Una huella es un resumen de la clave pública de identidad, producido por sgcE2EE_IdentityFingerprint. Dos extremos que tengan la misma clave de identidad producen la misma huella, y un intermediario que tenga una clave distinta no puede producir una que coincida.

Este es exactamente el mecanismo que hay detrás del "número de seguridad" de Signal y del "código de seguridad" de WhatsApp. Su valor está en que un humano puede compararlo por un canal que el atacante no controla. Alicia se lo lee a Bruno por teléfono, o se enseñan un código QR en persona, o lo pegan en un chat de confianza que ya existía. Si los dos valores coinciden, no hay nadie en medio. Si difieren, lo hay.

Dásela a tus usuarios en un formato que puedan leer en voz alta de verdad. Agrupar el resumen en bloques cortos es suficiente:

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;

Muestra tu propia huella en la aplicación, muestra la huella del par junto al contacto, y deja que el usuario marque un contacto como verificado una vez que haya comparado las dos. Ese es el momento en el que el cifrado significa de verdad lo que tus usuarios creen que significa.

Cuando cambia la clave de identidad de un par

OnE2EEKeyChange se dispara cuando un usuario presenta una clave de identidad distinta de la última que le viste, y te entrega tanto la huella antigua como la nueva.

Ten cuidado con cómo lo presentas. Una clave de identidad que cambia no es automáticamente un ataque. Un usuario que ha reinstalado la aplicación, que ha borrado un dispositivo o que ha iniciado sesión en un teléfono nuevo tendrá legítimamente una clave de identidad nueva, y esta es, con mucho, la causa más frecuente. Es, sin embargo, exactamente la misma señal que produciría un cambio de clave, así que es el momento en el que hay que avisar al usuario, y el momento en el que tu aplicación quizá quiera soltar la fijación y pedir una nueva comparación por un canal alternativo.

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;

Combínalo con el manejador anterior: OnE2EEKeyChange te dice que la clave se ha movido, y OnE2EEVerifyPeerIdentity es donde decides si sigues hablando.

Fallar de forma cerrada con RequireAuthentication

Con Identity.Enabled activado y RequireAuthentication en su valor por defecto de False, el cliente está en un modo de mejor esfuerzo. Los pares que presentan una firma válida quedan verificados. Los pares que no presentan ninguna firma, un cliente antiguo, un cliente que no ha habilitado la identidad, siguen siendo aceptados. Eso resulta cómodo durante un despliegue, cuando todavía no se han actualizado todos los extremos, pero no es una frontera de seguridad: un atacante puede sencillamente quitar la firma y parecer un cliente antiguo.

RequireAuthentication := True es el interruptor de fallo cerrado. La clave de un par sin firmar, o cuya firma no verifica, se rechaza en lugar de aceptarse.

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

Despliégalo en dos pasos. Distribuye las claves de identidad con RequireAuthentication desactivado, espera a que todo tu parque las tenga, y entonces actívalo. Una vez activado, un ataque de degradación que quite la firma deja de funcionar, porque una firma que falta es un rechazo en lugar de un encogimiento de hombros.

Chats de grupo

La mensajería de grupo recibe el mismo trato. Cada miembro firma su clave efímera con su propia clave de identidad, y la clave pública de identidad y la firma viajan con la entrada de cada miembro, así que unirse a un grupo verifica a todos los miembros para los que vas a cifrar, no solo al par que te invitó. OnE2EEVerifyPeerIdentity se dispara por cada miembro, así que la misma lógica de fijación que escribiste para los chats uno a uno vale sin cambios, y de un miembro cuya clave rechazas no derivas ningún secreto. RequireAuthentication se aplica en un grupo exactamente igual que en una conversación directa.

Compatible hacia atrás por defecto

Identity.Enabled viene como False de fábrica. Con él desactivado, no se envía ninguna clave de identidad, no se produce ninguna firma, no se espera ninguna, y el intercambio de claves es byte a byte lo que era antes. Actualizar la librería no cambia nada de un despliegue E2EE existente hasta que estableces la propiedad de forma explícita.

Los campos de identidad son aditivos en el cable, así que un cliente con la identidad habilitada sigue hablando con un cliente que no la tiene, siempre que RequireAuthentication esté desactivado. Eso es lo que te permite desplegarla de forma gradual en un parque que no actualizas de una sola vez.

Disponibilidad

La verificación de identidad en E2EE se incluye en sgcWebSockets 2026.7 para Delphi 7 a 13 y C++Builder, en Win32/Win64, Linux64, macOS, Android e iOS. Forma parte del protocolo E2EE, disponible en las ediciones Enterprise y All-Access, y funciona tanto para los mensajes uno a uno como para los chats de grupo.

Los clientes con una suscripción activa pueden descargar la nueva versión desde el área de clientes. Los usuarios de la versión de prueba pueden obtener el instalador actualizado en esegece.com/products/websockets/download.

¿Preguntas, comentarios o ayuda para integrar la verificación de identidad en tu aplicación? Ponte en contacto, recibirás una respuesta de las personas que escribieron el código.