Criptografia
Objectivos
- Conhecer os mecanismos criptográficos da plataforma Java
- Gerar pares de chaves assimétricas usando OpenSSL
- Concretizar um mecanismo de segurança, assinaturas digitais de chave pública, num projeto cliente-servidor baseado em gRPC
Segurança e criptografia em Java
A plataforma Java disponibiliza um conjunto abrangente de classes que fornece os mecanismos criptográficos de base, incluindo cifras simétricas e assimétricas, geração e gestão de chaves, funções de resumo (hash) e assinaturas digitais. Essas funcionalidades encontram-se na Java Cryptography Architecture (JCA), uma das componentes do Java Development Kit (JDK).
Este pequeno exemplo ilustra alguns mecanismos criptográficos da JCA.
Neste guião, iremos aprender a gerar assinaturas digitais de chave pública usando o suporte do JCA. Aplicaremos esse suporte para garantir que, num projeto cliente-servidor em gRPC, o cliente possa verificar a integridade e autenticidade das respostas que recebe do servidor. Além disso, a assinatura garante também a não repudiação.
Posteriormente, aprenderemos a cifrar mensagens confidenciais.
Como o programa que iremos compor precisa de um par de chaves assimétricas do servidor, num passo prévio recorreremos à ferramenta OpenSSL gerar essas chaves.
Exercício 1: Assinar mensagens em gRPC
Fornecedor gRPC / Supplier
O ponto de partida para o exercício é um serviço fornecedor de produtos para venda. O cliente contacta o servidor, chamando a operação remota listProducts, e o servidor responde com uma lista de produtos. Pode aceder à implementação base aqui.
- Faça Clone or Download do ponto de partida no repositório cliente-servidor que usa gRPC GitHub.
- Comecemos pela pasta
contract, onde vamos executar o comandomvn install. - De seguida, vá à pasta
servere execute o comandomvn compile exec:java -Ddebug. - Por fim, na pasta
clientexecute o comandomvn compile exec:java -Ddebug. - Verifique que o cliente recebe a lista de produtos do servidor.
Criação e distribuição de chaves
Num mundo real, a lista devolvida pelo servidor ao cliente pode ser intercetada e modificada por um atacante. Para permitir que o cliente, ao receber a mensagem, possa verificar se esta é autêntica (foi enviada pelo seu suposto emissor) e íntegra (não foi modificada no caminho), o emissor deve acrescentar-lhe uma assinatura digital.
Como iremos usar assinaturas digitais de chave pública, primeiro precisamos gerar um par de chaves assimétricas para o servidor e distribuir a sua chave pública ao cliente. O servidor utilizará a sua chave privada para assinar e o cliente verificará a assinatura com a chave pública do servidor.
Vamos começar por criar um par de chaves RSA, que é um algoritmo de criptografia assimétrica amplamente utilizado para assinaturas digitais. Para tal vamos usar o OpenSSL, uma ferramenta de linha de comando que suporta uma vasta gama de operações criptográficas. Siga os passos 1,2,3 e 4 para criar as chaves. Caso tenha algum problema na criação, nós oferecemos um par de chaves de exemplo e pode usá-las a partir do passo 5.
- Verifique que tem o OpenSSL instalado no seu sistema:
openssl version
- Idealmente, deverá ter uma versão recente do OpenSSL.
OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022)
-
Caso não o tenha, instale-o:
3.1. No Ubuntu, use o seguinte comando:
sudo apt update sudo apt install openssl3.2. No Windows, pode descarregar o OpenSSL a partir do site oficial: https://www.openssl.org/source/.
3.3 No macOS, pode usar o Homebrew para instalar o OpenSSL:
brew install openssl -
Gerar um par de chaves RSA no terminal é tão simples como:
openssl genrsa -out priv.key 2048 # gera a chave privada
openssl rsa -in priv.key -pubout -out pub.key # gera a chave pública a partir da chave privada
No entanto, como vamos usar Java neste exercício, vamos criar as chaves da seguinte maneira que as torna mais fáceis de importar no programa Java que comporemos em breve:
openssl genrsa -out private.pem 2048 # Gerar a chave privada RSA
openssl pkcs8 -topk8 -inform PEM -outform DER -in private.pem -out private.der -nocrypt # Converter a privada para PKCS#8 DER (O formato que o Java lê nativamente)
openssl rsa -in private.pem -pubout -outform DER -out public.der # Gerar a chave pública em formato X.509 DER
- Copie a chave privada (ficheiro
private.der) para o servidor (pastaserver/src/main/resources. Se a pastaresourcesnão existir, ela deve ser criada no diretóriomain).
Nota: Em sistemas reais, a chave pública é tipicamente distribuída através de uma infraestrutura de chave pública (PKI), num certificado digital de chave pública emitido por uma autoridade de certificação (CA). No entanto, por simplificação, neste exercício vamos entregar a chave pública manualmente ao cliente, copiando-a para a pasta do mesmo.
- Copie a chave pública (ficheiro
public.der) para o cliente (pastaclient/src/main/resources. Se a pastaresourcesnão existir, ela deve ser criada no diretóriomain). Deste modo criamos e distribuímos um par de chaves público-privadas usando o terminal. No entanto, também teria sido possível fazer isto com o Java, mediante a classe java.security.KeyPairGenerator.
Acrescentar assinatura à definição da operação
Vamos agora acrescentar uma assinatura à definição da mensagem de resposta da operação listProducts..
- Aceda à definição Protobuf no
contract. - Acrescente a definição de uma nova estrutura de dados (
message) para a assinatura, composta por identificador do assinante e o valor a calcular.
...
message Signature {
string signerIdentifier = 1;
bytes signatureValue = 2;
}
...
- Acrescente a
messagecom a assinatura digital à mensagem da resposta.
...
message SignedResponse {
ProductsResponse response = 1;
Signature signature = 2;
}
...
- Modifique o tipo do resultado da operação RPC:
...
rpc listProducts(ProductsRequest) returns (SignedResponse);
...
- Re-execute nas pasta
contracto comandomvn installpara atualizar o código Java gerado. - Efetue as alterações necessárias no código do servidor para refletir a modificação:
...
import pt.tecnico.supplier.grpc.SignedResponse;
import pt.tecnico.supplier.grpc.Signature;
...
@Override
public void listProducts(ProductsRequest request, StreamObserver<SignedResponse> responseObserver) {
// atualize o resto da função
...
- Atualize também a chamada do lado do cliente:
...
import pt.tecnico.supplier.grpc.SignedResponse;
import pt.tecnico.supplier.grpc.Signature;
...
SignedResponse response = stub.listProducts(request);
...
Assinar a resposta a enviar
A partir de agora, as mensagens enviadas pelo servidor serão assinadas. Note que muitas destas operações seguintes lançam exceções, que devem ser capturadas e tratadas adequadamente.
- Para tal, o servidor vai precisar da sua chave privada, que se encontra na pasta
src/main/resources.
import java.io.InputStream;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
...
private byte[] readResource(String path) throws Exception {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) {
if (is == null) {
throw new IllegalArgumentException("Ficheiro não encontrado: " + path);
}
return is.readAllBytes();
}
}
public static PrivateKey loadPrivateKey(String resourcePath) throws Exception {
byte[] keyBytes = readResource(resourcePath);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePrivate(spec);
}
Desta maneira o servidor pode obter a sua chave privada com o método que criámos: loadPrivateKey. Podemos obtê-la no momento da criação do serviço, por exemplo, chamando o método no contrutor:
public SupplierServiceImpl() {
debug("Loading demo data...");
supplier.demoData();
try {
this.privateKey = loadPrivateKey("private.der");
System.out.println("Chave privada do servidor carregada com sucesso.");
} catch (Exception e) {
System.err.println("Erro ao carregar a chave privada: " + e.getMessage());
e.printStackTrace();
}
}
- Para assinar vamos usar a classe
java.security.Signature. Primeiro obtemos uma instância do algoritmo que quisermos usar:
java.security.Signature sig = java.security.Signature.getInstance("SHA256withRSA");
- Esta instância pode desempenhar tanto a função de assinar como de verificar a assinatura. Como estamos no servidor, queremos que assine:
sig.initSign(this.privateKey);
- Carregamos todos os dados que quisermos assinar (pode ser chamado várias vezes), e assinamos, obtendo assim os bytes da assinatura.
sig.update(response.toByteArray());
byte[] signatureBytes = sig.sign();
- Por último, incluímos a assinatura na mensagem de resposta.
import com.google.protobuf.ByteString;
...
Signature signature = Signature.newBuilder()
.setSignerIdentifier(supplier.getId())
.setSignatureValue(ByteString.copyFrom(signatureBytes))
.build();
SignedResponse signedResponse = SignedResponse.newBuilder()
.setResponse(response)
.setSignature(signature)
.build();
responseObserver.onNext(signedResponse);
Nota: muitas vezes, o mais correto é enviar a assinatura como metadados gRPC.
Verificar a assinatura da resposta recebida
O cliente quer certificar-se que as mensagens recebidas foram realmente enviadas pelo servidor, pelo que iremos verificar que a assinatura das mensagens é válida.
- Para tal, iremos precisar da chave pública do servidor:
import java.io.InputStream;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
...
private static PublicKey loadPublicKey(String resourcePath) throws Exception {
byte[] keyBytes = readResource(resourcePath);
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePublic(spec);
}
private static byte[] readResource(String path) throws Exception {
try (InputStream is = SupplierClient.class.getClassLoader().getResourceAsStream(path)) {
if (is == null) {
throw new IllegalArgumentException("Ficheiro não encontrado nos resources: " + path);
}
return is.readAllBytes();
}
}
Tal como no servidor, estes são dois métodos auxiliares para importar a chave. Agora podemos obtê-la no inicio do main(), por exemplo:
PublicKey publicKey = null;
try {
publicKey = loadPublicKey("public.der");
System.out.println("Chave pública do servidor carregada com sucesso.");
} catch (Exception e) {
System.err.println("Erro ao carregar a chave pública: " + e.getMessage());
}
- Vamos criar uma instância de
Signaturee inicializá-la com a chave pública:
java.security.Signature sig = java.security.Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
- Para validar a assinatura digital, carregamos os dados que foram assinados e depois verificamos se a assinatura recebida coincide:
SignedResponse signedResponse = stub.listProducts(request);
ProductsResponse response = signedResponse.getResponse();
Signature receivedSignature = signedResponse.getSignature();
sig.update(response.toByteArray());
boolean isValid = sig.verify(receivedSignature.getSignatureValue().toByteArray());
Verificar eficácia da assinatura
Vamos vestir a pele de um atacante e modificar o conteúdo da mensagem de resposta depois de assinada, para confirmar que o cliente é capaz de detetar a alteração.
- No servidor, após a realização da assinatura, modifique um dos campos de um dos produtos. Os objetos construídos para os pedidos e respostas são imutáveis, ou seja, não podem ser mudados depois de construídos. Para criar um objeto modificado a partir de um objeto existente pode-se usar o método
toBuilder(), semelhante ao seguinte:
...
response = response.toBuilder().setSupplierIdentifier("intruder").build();
...
- Para testar, execute no server o comando
mvn compile exec:java -Ddebug. - De seguida, execute também no client o comando
mvn compile exec:java -Ddebug.
O seu projeto de SD precisa de assinaturas digitais? Este é um bom momento para pensar como pode aplicar no seu projeto o que aprendeu no exercício acima.
Exercício 2: Cifrar mensagens confidenciais
Assinar a mensagem proporciona três propriedades: autenticidade, integridade e não repudiação. No entanto, se a mensagem for intercetada, os atacantes podem ler os seus conteúdos. Caso os dados que as mensagens transportam sejam confidenciais, isso é um problema.
Vamos alterar o nosso programa para garantir, por mecanismos criptográficos, a confidencialidade das mensagens. Para tal, vamos cifrar a mensagem usando a chave pública do recetor.
- Os dados cifrados serão um conjunto de bytes, por isso vamos atualizar o protocolo:
message EncryptedResponse {
bytes encriptedPayload = 1;
Signature signature = 2;
}
...
rpc listProducts(ProductsRequest) returns (EncryptedResponse);
- A criptografia assimétrica é muito pesada e por isso não é adecuada para cifrar grandes pacotes de dados. Nestes casos, usamos chaves simétricas. Com este comando poderá criar uma chave simétrica AES-128 de 16 bytes:
openssl rand -out secret.key 16
Copie esta chave para a pasta src/main/resources do servidor e do cliente. Estamos a fazer esta distribuição manual das chaves simétricas para simplificar o exercício, mas normalmente usam-se algoritmos como Diffie-Helman ou PGP para acordar a chave a usar.
- Atualizar o servidor para cifrar a mensagem. Primeiro vamos importar a chave usando os métodos que criamos anteriormente:
import javax.crypto.spec.SecretKeySpec;
import javax.crypto.Cipher;
...
private SecretKeySpec aesKey;
...
byte[] aesKeyBytes = readResource("secret.key");
aesKey = new SecretKeySpec(aesKeyBytes, "AES");
Agora vamos cifrar os dados:
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, aesKey);
byte[] encryptedPayload = cipher.doFinal(response.toByteArray());
Resta enviar a EncryptedResponse correspondente. Não se esqueça de atualizar a função listProducts, uma vez que atualizamos o proto!
- Agora o cliente terá de receber a mensagem e decifrá-la. Devemos importar a chave simétrica que partilhamos com o servidor.
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
...
byte[] aesKeyBytes = readResource("secret.key");
SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");
E decifrar:
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, aesKey);
byte[] plainTextBytes = cipher.doFinal(cipherTextBytes);
ProductsResponse payload = ProductsResponse.parseFrom(plainTextBytes);
Deste modo, os dados estão a transitar encriptados, e mesmo que sejam intercetados não poderão ser lidos sem a chave simétrica!
O seu projeto de SD troca mensagens confidenciais? Este é um bom momento para pensar como pode aplicar no seu projeto o que aprendeu no exercício acima.