Quando apresentamos a criptografia ponta a ponta no sgcWebSockets, a garantia era clara: Alice e Bob derivam um segredo compartilhado com ECDH, criptografam com AES-GCM, e o servidor que encaminha o tráfego não vê nada além de texto cifrado. Essa parte é sólida. Mas existe uma etapa anterior a tudo isso que a própria criptografia não consegue proteger, e é justamente a etapa em que os ataques reais acontecem: Alice precisa obter a chave pública de Bob de algum lugar, e ela a obtém através do servidor.
Imagine o ataque de forma concreta. Alice pede ao servidor a chave pública de Bob. Um servidor comprometido, um relay malicioso, ou qualquer um que tenha assumido aquele salto, não encaminha a chave de Bob. Ele gera um par de chaves próprio e entrega a Alice a sua chave pública, rotulada como "Bob". Depois faz o mesmo no sentido inverso, entregando a Bob uma outra chave sua, rotulada como "Alice". Alice agora criptografa perfeitamente, para o atacante. O atacante descriptografa, lê, possivelmente reescreve, criptografa novamente para a chave real de Bob e encaminha. Bob descriptografa perfeitamente, a partir do atacante. Os dois lados veem um cadeado verde. Os dois lados estão errados. Esse é o clássico ataque man-in-the-middle com troca de chaves, e nenhuma quantidade de AES-GCM resolve isso, porque as duas metades da conversa estão genuinamente e corretamente criptografadas. Só que estão criptografadas para a pessoa errada.
A partir da versão 2026.7, o sgcWebSockets fecha essa brecha. Cada extremidade pode manter uma chave de identidade de longo prazo, assinar com ela a sua chave efêmera de criptografia, e o par verifica essa assinatura antes mesmo de derivar um segredo compartilhado. A sua aplicação decide o que fazer com a impressão digital do par: aceitá-la no primeiro uso e fixá-la, compará-la por um canal externo, ou recusar qualquer chave que não venha assinada. Vem desativado por padrão, e o formato na rede permanece inalterado até que você o ative.
Criptografia não é autenticação
São duas propriedades diferentes e vale a pena ser franco sobre qual delas você tem.
A criptografia responde à pergunta "um terceiro consegue ler isto?". ECDH mais AES-GCM responde a isso: não. A autenticação responde à pergunta "a chave para a qual criptografei é realmente a chave da pessoa com quem penso estar falando?". O ECDH puro não responde a isso de forma alguma. Ele fecha alegremente um segredo compartilhado com quem quer que tenha enviado a chave, e não tem opinião nenhuma sobre quem foi.
Tudo neste artigo trata da segunda pergunta. Nada aqui muda o conjunto de cifras, a derivação de chaves ou o formato das mensagens. O que se adiciona é uma assinatura sobre a chave efêmera, e um lugar onde a sua aplicação pode dizer sim ou não ao par que está por trás dela.
Chaves de identidade de longo prazo
Agora existem dois tipos de chave em uma sessão E2EE, e manter a distinção clara é a ideia central.
A chave efêmera de criptografia é o par de chaves ECDH que o cliente já usava. Ela existe durante a sessão, é dela que o segredo compartilhado é derivado, e pode ser regenerada livremente. Ela não diz nada sobre quem você é.
A chave de identidade é um par de chaves ECDSA P-256 de longo prazo. Você a gera uma vez, guarda a metade privada no dispositivo, e a mantém entre reinicializações, reconexões e novas sessões. A única função dela é assinar a chave efêmera: "a chave pública efêmera que você acabou de receber foi realmente publicada pelo detentor desta chave de identidade". O par verifica essa assinatura com a chave pública de identidade que viajou junto com ela, e o servidor, que continua podendo ver e retransmitir ambas, não consegue forjar a assinatura porque não tem a chave privada de identidade.
Isso reduz o problema do man-in-the-middle a uma única pergunta: esta chave pública de identidade é mesmo a que pertence ao meu par? Essa é uma pergunta que a sua aplicação consegue responder, porque, ao contrário de uma chave efêmera aleatória, uma chave de identidade é estável, portanto pode ser lembrada, fixada e comparada por um ser humano.
Gerando e armazenando uma chave de identidade
Um auxiliar em sgcSSL_E2EE cria o par de chaves como strings PEM. Chame-o uma vez por instalação, por usuário, e guarde a chave privada onde a sua aplicação guarda segredos. Nunca envie a chave privada para lugar nenhum.
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;
O mesmo par de chaves também pode ser gerado pelo próprio cliente E2EE com GenerateIdentityKeyPair, se você preferir mantê-lo no componente:
var
vPrivateKeyPEM, vPublicKeyPEM: string;
begin
oE2EEClient.GenerateIdentityKeyPair(vPrivateKeyPEM, vPublicKeyPEM);
end;
O armazenamento é uma decisão sua, e é a parte que merece cuidado. A chave privada é aquilo que prova que você é você. Coloque-a no keystore da plataforma, em um blob protegido por DPAPI, em um arquivo de configurações criptografado, onde quer que o seu modelo de ameaças mande. Se ela vazar, um atacante poderá se passar por aquela identidade, e a impressão digital que os seus usuários compararam ainda vai bater.
Ativando
A assinatura de identidade fica em E2EE_Options.Identity. Carregue o par de chaves armazenado, defina Enabled, e o cliente passa a assinar a sua chave efêmera e a verificar as assinaturas que recebe.
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 daí, a chave pública efêmera que o cliente publica vem acompanhada da chave pública de identidade e de uma assinatura sobre essa chave efêmera. Quando a chave de um par chega, a assinatura é conferida contra a chave de identidade que veio junto com ela. Se a assinatura não for válida, a chave do par é rejeitada e nenhum segredo compartilhado é derivado dela.
Repare bem no que uma assinatura válida prova e no que não prova. Ela prova que a chave efêmera foi assinada pelo detentor daquela chave privada de identidade. Ela não prova, por si só, que aquela chave de identidade pertence ao seu par, porque um man-in-the-middle pode apresentar a sua própria chave de identidade com a sua própria assinatura perfeitamente válida. Essa última milha é o assunto das duas próximas seções.
Verificando um par: confiança no primeiro uso e fixação
Assim que uma assinatura é validada, OnE2EEVerifyPeerIdentity dispara com o id de usuário do par, a chave pública de identidade e a sua impressão digital. O parâmetro aAccept é um var e chega como True, portanto, se você não fizer nada, o par é aceito. A decisão é deliberadamente sua, porque só a sua aplicação sabe se já viu aquele contato antes.
O padrão habitual é confiança no primeiro uso mais fixação. Na primeira vez em que você vê um usuário, registre a impressão digital. Em todas as vezes seguintes, compare. Se ela bater, aceite em silêncio. Se não bater, não aceite, e avise o usuário.
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;
A confiança no primeiro uso tem uma limitação honesta que vale mencionar: se o atacante já estava no meio do caminho naquele primeiro contato, você fixa o atacante. O que ela garante é que o atacante precisa estar ali desde o início e permanecer ali para sempre, e que qualquer tentativa posterior de trocar a chave faz barulho. Se você precisa fechar também a brecha do primeiro contato, compare a impressão digital por um canal externo, que é o assunto da próxima seção.
Impressões digitais que os seus usuários realmente conseguem comparar
Uma impressão digital é um resumo criptográfico da chave pública de identidade, produzido por sgcE2EE_IdentityFingerprint. Duas extremidades que possuem a mesma chave de identidade produzem a mesma impressão digital, e um man-in-the-middle que possui uma chave diferente não consegue produzir uma impressão que coincida.
Esse é exatamente o mecanismo por trás do "número de segurança" do Signal e do "código de segurança" do WhatsApp. O valor dele está em ser comparável por um ser humano através de um canal que o atacante não controla. Alice o lê em voz alta por telefone para Bob, ou eles mostram um ao outro um código QR pessoalmente, ou o colam em um chat de confiança já existente. Se os dois valores coincidem, não há ninguém no meio. Se diferem, há.
Ofereça essa informação aos seus usuários em um formato que eles consigam de fato ler em voz alta. Agrupar o resumo em blocos curtos já basta:
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;
Mostre a sua própria impressão digital na aplicação, mostre a impressão digital do par ao lado do contato, e deixe o usuário marcar um contato como verificado assim que tiver comparado as duas. É nesse ponto que a criptografia passa a significar de fato o que os seus usuários acham que ela significa.
Quando a chave de identidade de um par muda
OnE2EEKeyChange dispara quando um usuário apresenta uma chave de identidade diferente da última que você viu para ele, e lhe entrega tanto a impressão digital antiga quanto a nova.
Tenha cuidado com a forma de apresentar isso. Uma chave de identidade alterada não é automaticamente um ataque. Um usuário que reinstalou o aplicativo, apagou um dispositivo, ou entrou em um telefone novo terá legitimamente uma nova chave de identidade, e essa é de longe a causa mais comum. Ainda assim, é exatamente o sinal que uma troca de chaves produziria, portanto é o momento de avisar o usuário, e o momento em que a sua aplicação pode querer descartar a fixação e pedir uma nova comparação por canal externo.
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;
Combine-o com o manipulador anterior: OnE2EEKeyChange avisa que a chave mudou, e OnE2EEVerifyPeerIdentity é onde você decide se continua conversando.
Recusar por padrão com RequireAuthentication
Com Identity.Enabled ativado e RequireAuthentication no seu padrão False, o cliente fica em um modo de melhor esforço. Os pares que apresentam uma assinatura válida são verificados. Os pares que não apresentam assinatura nenhuma, um cliente antigo, um cliente que não habilitou a identidade, continuam sendo aceitos. Isso é conveniente durante uma implantação gradual, quando nem todas as extremidades foram atualizadas, mas não é uma fronteira de segurança: um atacante pode simplesmente remover a assinatura e se passar por um cliente antigo.
RequireAuthentication := True é a chave que faz o sistema recusar por padrão. Uma chave de par sem assinatura, ou cuja assinatura não é válida, é recusada em vez de aceita.
// ... 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;
Implante isso em duas etapas. Distribua as chaves de identidade com RequireAuthentication desativado, espere até que todo o seu parque as tenha, e então ative. Uma vez ativado, um ataque de downgrade que remove a assinatura deixa de funcionar, porque uma assinatura ausente passa a ser uma rejeição em vez de um dar de ombros.
Chats em grupo
As mensagens em grupo recebem o mesmo tratamento. Cada membro assina a sua chave efêmera com a sua própria chave de identidade, e a chave pública de identidade e a assinatura viajam junto com a entrada de cada membro, de modo que entrar em um grupo verifica todos os membros para os quais você está prestes a criptografar, não apenas o par que o convidou. OnE2EEVerifyPeerIdentity dispara para cada membro, então a mesma lógica de fixação que você escreveu para as conversas um a um se aplica sem mudanças, e um membro cuja chave você recusa não é um membro com quem você deriva um segredo. RequireAuthentication vale em um grupo exatamente como vale em uma conversa direta.
Compatível com versões anteriores por padrão
Identity.Enabled vem como False de fábrica. Com ele desativado, nenhuma chave de identidade é enviada, nenhuma assinatura é produzida, nenhuma é esperada, e a troca de chaves é byte a byte a mesma de antes. Atualizar a biblioteca não muda nada em uma implantação E2EE existente até que você defina a propriedade explicitamente.
Os campos de identidade são aditivos na rede, portanto um cliente com identidade habilitada continua conversando com um cliente sem ela, desde que RequireAuthentication esteja desativado. É isso que permite implantar o recurso gradualmente em um parque que você não atualiza todo de uma vez.
Disponibilidade
A verificação de identidade E2EE chega no sgcWebSockets 2026.7 para Delphi 7 até 13 e C++Builder, em Win32/Win64, Linux64, macOS, Android e iOS. Faz parte do protocolo E2EE, disponível nas edições Enterprise e All-Access, e funciona tanto para mensagens um a um quanto para chats em grupo.
Os clientes com assinatura ativa podem baixar a nova versão na área de clientes. Os usuários de avaliação podem obter o instalador atualizado em esegece.com/products/websockets/download.
Dúvidas, comentários ou ajuda para integrar a verificação de identidade ao seu aplicativo? Fale conosco, você receberá uma resposta das pessoas que escreveram o código.
