Criptografia e comunicação segura
Programas que trabalham com redes frequentemente precisam transmitir informações que não devem ser acessadas ou modificadas por terceiros. Essas informações podem incluir credenciais, tokens, dados pessoais ou mensagens trocadas entre diferentes sistemas. Para proteger essa comunicação, podemos aplicar mecanismos criptográficos que permitem garantir propriedades como confidencialidade, integridade e autenticidade.
Em Go, podemos trabalhar com criptografia usando os pacotes disponíveis em crypto, que faz parte da biblioteca padrão. Esses pacotes fornecem implementações de algoritmos conhecidos, incluindo SHA-256, AES, RSA e ECDSA. Também podemos usar o pacote crypto/tls para estabelecer conexões de rede protegidas, evitando precisar implementar manualmente os mecanismos de segurança usados durante a comunicação.
Cada recurso criptográfico resolve um problema diferente. Um hash permite calcular um identificador derivado de determinados dados, enquanto a criptografia permite proteger informações contra leitura não autorizada. Assinaturas digitais ajudam a verificar a origem e a integridade dos dados, e protocolos como TLS combinam diferentes mecanismos para proteger a comunicação entre clientes e servidores.
A escolha correta desses mecanismos é tão importante quanto sua implementação. Um algoritmo seguro pode ser aplicado de maneira inadequada quando usamos chaves fracas, reutilizamos valores que deveriam ser únicos ou deixamos de validar a identidade de quem está se comunicando. Por isso, precisamos entender primeiro o objetivo de cada recurso e depois como trabalhar com ele usando as APIs disponibilizadas pelo Go.
Conceitos básicos de criptografia
Criptografia é uma área responsável pelo desenvolvimento e pela aplicação de mecanismos matemáticos usados para proteger informações. Esses mecanismos permitem transformar dados, verificar sua integridade ou comprovar que determinada operação foi realizada por quem possui uma chave específica. Dependendo do objetivo, diferentes algoritmos e protocolos podem ser combinados para oferecer as propriedades de segurança necessárias.
Em uma comunicação pela rede, por exemplo, podemos precisar impedir que terceiros leiam as mensagens transmitidas. Também podemos precisar identificar alterações realizadas durante o transporte e verificar se estamos realmente conectados ao servidor esperado. Essas necessidades são diferentes, por isso não devemos tratar criptografia como um único mecanismo responsável por resolver todos os problemas.
Confidencialidade, integridade e autenticidade
A confidencialidade representa a proteção das informações contra leitura por entidades não autorizadas. Quando criptografamos uma mensagem adequadamente, seu conteúdo deixa de ser compreensível para quem não possui os recursos necessários para recuperá-lo. Essa propriedade é importante quando transmitimos senhas, tokens, documentos ou outras informações que não devem ficar disponíveis para terceiros.
A integridade representa a capacidade de identificar alterações nos dados. Uma informação pode chegar ao destino com conteúdo diferente daquele enviado originalmente, seja por falhas de transmissão ou por modificações intencionais. Mecanismos criptográficos de autenticação permitem identificar determinadas alterações e rejeitar informações que não correspondem ao conteúdo esperado.
A autenticidade está relacionada à verificação da origem ou da identidade associada a uma informação. Em uma conexão segura, por exemplo, precisamos verificar se o servidor apresentado corresponde ao destino que pretendíamos acessar. Essa verificação normalmente envolve certificados digitais e mecanismos criptográficos que permitem demonstrar a posse de uma chave privada.
Também existe a propriedade conhecida como não repúdio, relacionada à possibilidade de apresentar evidências de que determinada entidade realizou uma operação. Assinaturas digitais podem contribuir para esse objetivo, mas não garantem não repúdio automaticamente. O resultado depende de fatores como a proteção da chave privada, a identidade associada ao certificado e as regras usadas para registrar e validar a operação.
Algoritmos, chaves e protocolos
Um algoritmo criptográfico representa um conjunto de operações matemáticas usado para processar informações. AES, RSA e SHA-256 são exemplos de algoritmos, mas possuem responsabilidades diferentes. Alguns trabalham com criptografia, outros com assinaturas e outros com funções de hash. Por isso, precisamos escolher o algoritmo conforme a propriedade que queremos oferecer.
Uma chave criptográfica é um valor usado por determinados algoritmos para controlar operações como criptografar, descriptografar, assinar ou verificar dados. Dependendo do mecanismo, podemos trabalhar com uma única chave compartilhada ou com um par formado por uma chave pública e uma chave privada. A segurança depende tanto das características do algoritmo quanto da proteção e do uso correto dessas chaves.
Um protocolo define regras para realizar uma comunicação ou operação usando diferentes mecanismos. TLS, por exemplo, não representa apenas um algoritmo de criptografia. Ele define procedimentos para negociar parâmetros, autenticar participantes e estabelecer chaves que serão usadas para proteger os dados transmitidos durante a conexão.
Essa separação permite entender por que não precisamos escolher manualmente um algoritmo diferente para cada etapa de uma conexão TLS. O protocolo já define como diferentes mecanismos devem trabalhar juntos, enquanto sua implementação realiza as operações necessárias. Normalmente, nossa responsabilidade é configurar corretamente o serviço, validar certificados e manter as versões e configurações de segurança adequadas.
Hash, encoding e criptografia
Hash, encoding e criptografia são mecanismos que podem transformar a representação de informações, mas possuem finalidades diferentes. Uma função de hash calcula um valor derivado dos dados recebidos, enquanto encoding modifica a maneira como esses dados são representados. Já a criptografia é usada para proteger informações, permitindo recuperá-las somente quando os requisitos criptográficos correspondentes são atendidos.
Essa diferença precisa ficar clara porque é comum encontrar dados codificados em Base64 e considerar que estão criptografados. Entretanto, Base64 apenas representa bytes usando caracteres de um conjunto conhecido, permitindo recuperar o conteúdo sem nenhuma chave secreta. Da mesma maneira, um hash não representa uma mensagem criptografada que poderá ser descriptografada posteriormente.
| Mecanismo | Responsabilidade | Pode recuperar o conteúdo original? |
|---|---|---|
| Hash | Calcular um resumo dos dados | Não por uma operação inversa |
| Encoding | Alterar a representação dos dados | Sim, usando a decodificação correspondente |
| Criptografia | Proteger o conteúdo dos dados | Sim, com a chave e os parâmetros necessários |
Podemos usar esses mecanismos em conjunto. Uma aplicação pode criptografar determinados bytes, codificar o resultado em Base64 e depois armazená-lo em um arquivo de texto. Nesse caso, a proteção é fornecida pela criptografia, enquanto Base64 apenas permite representar o resultado binário em um formato adequado ao armazenamento ou à transmissão.
Entendendo funções de hash
Uma função de hash recebe dados de tamanho variável e calcula um resultado com tamanho determinado pelo algoritmo. No SHA-256, por exemplo, o resultado possui 256 bits, equivalentes a 32 bytes. Esse resultado também é chamado de digest e pode ser usado para comparar informações ou participar de outros mecanismos criptográficos.
Uma característica importante das funções de hash criptográficas é a dificuldade de encontrar dados diferentes que produzam o mesmo resultado. Isso é conhecido como resistência a colisões, embora colisões sejam matematicamente possíveis porque o conjunto de entradas é maior do que o conjunto de resultados. A segurança depende de tornar essas colisões suficientemente difíceis de encontrar na prática.
Também esperamos que seja computacionalmente difícil recuperar uma entrada desconhecida apenas a partir do hash. Entretanto, isso não significa que um hash esconda automaticamente informações previsíveis. Quando os dados originais pertencem a um conjunto pequeno de possibilidades, um atacante pode calcular hashes dessas possibilidades e comparar os resultados.
Trabalhando com Base64
Base64 é uma codificação usada para representar bytes em uma sequência de caracteres ASCII. Ela é conveniente quando precisamos transportar dados binários por mecanismos que trabalham principalmente com texto, como determinados formatos de configuração ou protocolos. A codificação não exige uma chave e pode ser revertida por qualquer programa que conheça suas regras.
Em Go, podemos trabalhar com Base64 usando o pacote encoding/base64. A função de codificação recebe os bytes originais e retorna uma string, enquanto a decodificação realiza a operação inversa. Podemos demonstrar esse comportamento usando uma mensagem simples.
package main
import (
"encoding/base64"
"fmt"
)
func main() {
// Define os dados que serão codificados.
mensagem := []byte("Olá, Go!")
// Converte os bytes para Base64.
codificado := base64.StdEncoding.EncodeToString(
mensagem,
)
fmt.Println("Base64:", codificado)
// Recupera os bytes originais.
decodificado, err := base64.StdEncoding.DecodeString(
codificado,
)
if err != nil {
fmt.Println("Erro ao decodificar:", err)
return
}
fmt.Println("Original:", string(decodificado))
}
A função EncodeToString recebe um slice de bytes e devolve sua representação Base64. Depois, DecodeString interpreta os caracteres e recupera os bytes correspondentes. Como não existe uma chave envolvida nessas operações, qualquer pessoa que receba a string codificada poderá executar a mesma decodificação.
A variável mensagem é convertida para []byte porque a codificação trabalha diretamente com bytes. Isso permite representar tanto texto quanto dados binários, sem exigir que o conteúdo original corresponda a caracteres legíveis. Quando usamos string(decodificado), apenas escolhemos apresentar os bytes recuperados como texto.
Por isso, não devemos usar Base64 para proteger senhas, tokens ou outras informações sensíveis. Se precisamos impedir que terceiros leiam o conteúdo, devemos aplicar criptografia adequada antes de armazenar ou transmitir os dados. Base64 pode continuar sendo usado depois dessa operação, mas não será responsável pela segurança.
Hash não é criptografia
Uma função de hash não possui uma operação de descriptografia correspondente. Quando calculamos SHA-256 de uma mensagem, obtemos um digest, mas não existe uma função que simplesmente receba esse resultado e devolva os dados originais. Esse comportamento é diferente da criptografia, na qual a recuperação do conteúdo faz parte do mecanismo quando usamos a chave correta.
Entretanto, não devemos concluir que toda informação representada por hash está protegida contra descoberta. Se conhecemos as possíveis entradas, podemos calcular os respectivos hashes e comparar com um valor armazenado. Isso é especialmente relevante para senhas, porque muitas pessoas usam valores previsíveis que podem ser testados por ferramentas automatizadas.
Para armazenar senhas, não devemos aplicar SHA-256 diretamente e considerar o resultado suficiente. Normalmente precisamos de algoritmos específicos para derivação e armazenamento de senhas, como Argon2id, scrypt ou bcrypt, usando configurações adequadas e salts exclusivos. Essas soluções foram desenvolvidas para dificultar tentativas de descoberta de senhas, enquanto SHA-256 foi projetado para outras finalidades.
Trabalhando com SHA-256
SHA-256 é uma função de hash criptográfica pertencente à família SHA-2. Ela recebe uma sequência de bytes e produz um digest de 256 bits, independentemente do tamanho da entrada. Isso permite calcular resumos de mensagens, arquivos e outras informações, sendo útil em verificações de integridade e em diferentes protocolos criptográficos.
O mesmo conteúdo sempre produz o mesmo hash quando usamos o mesmo algoritmo. Entretanto, uma alteração nos dados pode produzir um resultado completamente diferente, mesmo quando modificamos apenas um byte. Essa característica permite detectar alterações por comparação, desde que exista uma referência confiável para o valor esperado.
Em Go, podemos trabalhar com SHA-256 usando o pacote crypto/sha256, que oferece a função Sum256 para calcular o hash de um conjunto de bytes. Também podemos usar sha256.New quando precisamos processar informações gradualmente, como acontece durante a leitura de arquivos grandes.
Calculando o SHA-256 de uma string
A função sha256.Sum256 recebe um slice de bytes e retorna um array com 32 bytes, representando o digest calculado. Como strings precisam ser convertidas para bytes antes da operação, normalmente usamos []byte para fornecer os dados. Depois, podemos apresentar o resultado em hexadecimal, que é uma representação textual conveniente para valores binários.
package main
import (
"crypto/sha256"
"fmt"
)
func main() {
// Define a mensagem que será processada.
mensagem := "Olá, Go!"
// Calcula o SHA-256 dos bytes da mensagem.
hash := sha256.Sum256([]byte(mensagem))
// Apresenta o resultado em hexadecimal.
fmt.Printf("SHA-256: %x\n", hash)
}
A função Sum256 calcula o digest completo e retorna um valor do tipo [32]byte. Esse tipo representa um array com tamanho fixo, diferentemente de um slice, que possui um comprimento associado ao valor. Como SHA-256 sempre produz 32 bytes, o retorno utiliza uma estrutura de tamanho conhecido.
O formato %x da função fmt.Printf apresenta os bytes usando caracteres hexadecimais. Cada byte é representado por dois caracteres, portanto o digest de SHA-256 aparece como uma sequência de 64 caracteres hexadecimais. Essa representação é conveniente para registros e comparações, mas o conteúdo criptográfico continua sendo formado pelos 32 bytes originais.
Se executarmos o programa várias vezes usando exatamente a mesma mensagem, receberemos o mesmo hash. Entretanto, a codificação dos caracteres também faz parte dos dados processados. Isso significa que diferenças em espaços, quebras de linha ou representações de caracteres podem produzir resultados diferentes.
Comparando hashes
Podemos usar SHA-256 para comparar dois conjuntos de dados e identificar se seus digests são iguais. Como sha256.Sum256 retorna arrays de tamanho fixo, podemos comparar os resultados diretamente usando o operador ==. Essa operação é conveniente para exemplos simples de verificação de integridade, principalmente quando os valores comparados não representam segredos.
Vamos calcular o hash de duas mensagens iguais e de uma mensagem diferente. Depois, compararemos os resultados para observar como uma alteração no conteúdo afeta a igualdade dos digests.
package main
import (
"crypto/sha256"
"fmt"
)
func main() {
// Calcula os hashes de três mensagens.
primeiro := sha256.Sum256([]byte("mensagem"))
segundo := sha256.Sum256([]byte("mensagem"))
terceiro := sha256.Sum256([]byte("Mensagem"))
// Compara os resultados calculados.
fmt.Println("Iguais:", primeiro == segundo)
fmt.Println("Diferentes:", primeiro == terceiro)
}
A saída será:
Iguais: true
Diferentes: false
As duas primeiras mensagens possuem exatamente os mesmos bytes, por isso produzem o mesmo digest. Já a terceira começa com uma letra maiúscula, alterando o conteúdo processado e produzindo outro resultado. Isso demonstra como podemos identificar alterações comparando hashes calculados pelo mesmo algoritmo.
Entretanto, comparar um hash com outro valor recebido pela mesma conexão não fornece autenticação por si só. Se alguém consegue modificar a mensagem e também substituir seu hash, poderá fazer com que os dois valores continuem correspondendo. Para verificar a autenticidade dos dados, precisamos usar mecanismos como HMAC ou assinaturas digitais, que dependem de chaves criptográficas.
Calculando o hash de um arquivo
Quando precisamos verificar a integridade de arquivos, podemos calcular SHA-256 de seu conteúdo e comparar o resultado com um digest conhecido. Essa abordagem é usada, por exemplo, para verificar se um arquivo foi alterado durante uma transferência. Entretanto, a referência usada na comparação precisa ser obtida de uma fonte confiável para que a verificação tenha significado.
Para arquivos grandes, normalmente não queremos carregar todos os dados na memória. Podemos usar sha256.New, que retorna um objeto responsável por calcular o hash gradualmente, e combinar esse recurso com io.Copy. Dessa maneira, o arquivo é lido em blocos enquanto o digest é atualizado.
package main
import (
"crypto/sha256"
"fmt"
"io"
"os"
)
func main() {
// Abre o arquivo que será verificado.
arquivo, err := os.Open("arquivo.txt")
if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao abrir arquivo:",
err,
)
return
}
defer arquivo.Close()
// Cria um objeto responsável pelo cálculo do SHA-256.
hash := sha256.New()
// Lê o arquivo e atualiza o hash gradualmente.
_, err = io.Copy(hash, arquivo)
if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao ler arquivo:",
err,
)
return
}
// Obtém o digest calculado.
resultado := hash.Sum(nil)
fmt.Printf("SHA-256: %x\n", resultado)
}
A função os.Open abre o arquivo para leitura, enquanto sha256.New cria um objeto que mantém o estado do cálculo. A função io.Copy lê os dados do arquivo e os encaminha para esse objeto, que atualiza o digest conforme recebe novos bytes. Isso permite processar arquivos grandes sem manter todo o conteúdo simultaneamente na memória.
Depois que a leitura termina, chamamos hash.Sum(nil) para obter o resultado calculado. Diferentemente de sha256.Sum256, que retorna um array, esse método devolve um slice de bytes contendo o digest. O resultado continua possuindo 32 bytes, porque estamos usando o mesmo algoritmo SHA-256.
Também precisamos lembrar que o nome do arquivo não faz parte do cálculo nesse exemplo. O hash representa somente os bytes que foram lidos, portanto arquivos com nomes diferentes podem produzir o mesmo resultado quando possuem exatamente o mesmo conteúdo. Se o arquivo for modificado durante a leitura, o digest poderá representar os dados observados ao longo daquela operação.
Gerando valores aleatórios seguros
Algoritmos criptográficos frequentemente dependem de valores que não podem ser previstos por terceiros. Esses valores podem representar chaves, tokens, identificadores de sessão ou informações usadas durante determinadas operações criptográficas. Para que sejam adequados, precisamos usar uma fonte de aleatoriedade desenvolvida para aplicações de segurança, e não apenas um gerador de números destinado a simulações.
Em Go, o pacote crypto/rand fornece um gerador criptograficamente seguro, apoiado pelos mecanismos de aleatoriedade disponibilizados pelo sistema operacional. Isso o diferencia de geradores voltados para usos gerais, que podem produzir sequências previsíveis quando conhecemos seu estado ou sua semente. Para gerar informações secretas, normalmente devemos escolher crypto/rand.
A qualidade dos valores aleatórios influencia diretamente a segurança de outros mecanismos. Uma chave criptográfica previsível pode permitir a recuperação de informações protegidas, enquanto tokens previsíveis podem facilitar acessos não autorizados. Por isso, não devemos criar nossas próprias fórmulas para gerar segredos quando a biblioteca padrão já fornece recursos adequados.
Gerando bytes aleatórios
O pacote crypto/rand possui a função Read, que preenche um slice de bytes com dados criptograficamente seguros. Podemos definir a quantidade de bytes desejada e passar o slice para essa função, recebendo valores adequados para diferentes operações. Em versões atuais do Go, rand.Read preenche completamente o buffer ou interrompe a execução de maneira irrecuperável caso a fonte segura falhe.
Vamos gerar 32 bytes aleatórios, quantidade equivalente a 256 bits. Esse tamanho pode ser usado para representar uma chave AES-256, desde que os bytes sejam armazenados e administrados corretamente.
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
func main() {
// Reserva espaço para 32 bytes aleatórios.
chave := make([]byte, 32)
// Preenche o buffer com dados criptograficamente seguros.
rand.Read(chave)
// Converte os bytes para uma representação hexadecimal.
texto := hex.EncodeToString(chave)
fmt.Println("Valor gerado:", texto)
}
A função rand.Read preenche o slice chave com bytes aleatórios, enquanto hex.EncodeToString converte esses valores para hexadecimal. Como cada byte é representado por dois caracteres, o resultado terá 64 caracteres hexadecimais. Essa conversão não modifica a quantidade de informação aleatória originalmente gerada, apenas altera sua representação textual.
Cada execução deverá produzir um valor diferente com probabilidade extremamente alta. Entretanto, valores aleatórios podem coincidir matematicamente, por isso não devemos considerar a diferença entre execuções uma garantia absoluta. A segurança depende da dificuldade de prever os valores e da probabilidade suficientemente pequena de colisões para a finalidade pretendida.
Nesse exemplo, apresentamos os bytes no terminal apenas para demonstrar a geração. Em aplicações reais, não devemos imprimir chaves secretas em logs ou mensagens de diagnóstico, porque isso pode permitir que terceiros tenham acesso ao material usado para proteger informações. Também precisamos definir como as chaves serão armazenadas e recuperadas entre diferentes execuções.
Gerando tokens aleatórios
Tokens são valores usados para representar determinadas autorizações, sessões ou operações. Quando um token precisa ser imprevisível, podemos gerar bytes usando crypto/rand e depois convertê-los para uma representação textual adequada ao mecanismo que irá transportá-lo. Uma possibilidade é usar Base64 com codificação própria para URLs, evitando determinados caracteres que possuem significado especial nesse contexto.
O pacote encoding/base64 disponibiliza RawURLEncoding, que usa uma representação compatível com URLs e não acrescenta caracteres de preenchimento ao final. Podemos combinar esse mecanismo com 32 bytes aleatórios para criar um token textual.
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
func main() {
// Gera 32 bytes aleatórios.
dados := make([]byte, 32)
rand.Read(dados)
// Converte os bytes para Base64 compatível com URLs.
token := base64.RawURLEncoding.EncodeToString(
dados,
)
fmt.Println("Token:", token)
}
O token possui 256 bits de aleatoriedade antes da codificação, porque foi gerado a partir de 32 bytes. O Base64 apenas transforma esses bytes em texto, sem acrescentar ou reduzir a imprevisibilidade original. A segurança do token depende principalmente dos valores aleatórios gerados e da maneira como ele será armazenado, transmitido e validado.
Também podemos usar rand.Text, disponível a partir do Go 1.24, quando precisamos gerar diretamente uma string criptograficamente aleatória. Essa função produz um texto usando um alfabeto Base32 e oferece pelo menos 128 bits de aleatoriedade. Entretanto, quando precisamos controlar exatamente a quantidade de bytes ou um formato específico, gerar os bytes e aplicar a codificação desejada continua sendo uma abordagem adequada.
O valor gerado não implementa automaticamente mecanismos de expiração, revogação ou associação a um usuário. Esses comportamentos pertencem à aplicação que administra os tokens. Portanto, gerar um valor imprevisível é apenas uma parte do processo de criar um sistema de autenticação ou autorização seguro.
Criptografia simétrica com AES
A criptografia simétrica usa uma mesma chave secreta para proteger e recuperar informações. Isso significa que os participantes autorizados precisam conhecer a chave correspondente, permitindo criptografar e descriptografar os dados. Esse modelo é adequado para proteger grandes quantidades de informações, porque algoritmos simétricos normalmente possuem desempenho apropriado para operações frequentes.
AES, que significa Advanced Encryption Standard, é um algoritmo de criptografia simétrica amplamente usado em diferentes sistemas. Ele trabalha com blocos de 128 bits e aceita chaves de 128, 192 ou 256 bits. Entretanto, AES sozinho representa uma cifra de bloco, por isso precisamos escolher um modo de operação adequado para proteger mensagens de diferentes tamanhos.
Para novas implementações, normalmente devemos preferir modos de criptografia autenticada, como AES-GCM. Esse modo permite proteger a confidencialidade dos dados e detectar alterações no conteúdo criptografado. Em Go, podemos combinar os pacotes crypto/aes e crypto/cipher para trabalhar com AES-GCM sem implementar manualmente suas operações matemáticas.
Entendendo AES-GCM
GCM, que significa Galois/Counter Mode, é um modo de operação que combina criptografia com autenticação dos dados. Ele permite transformar uma mensagem em texto cifrado e produzir informações adicionais usadas para verificar sua integridade durante a descriptografia. Esse modelo é conhecido como AEAD, que significa Authenticated Encryption with Associated Data.
Uma operação AES-GCM normalmente envolve uma chave secreta, um nonce e os dados que serão protegidos. O nonce é um valor que precisa ser único para cada operação de criptografia realizada com a mesma chave. Ele não precisa ser secreto, mas sua reutilização com a mesma chave pode comprometer seriamente a segurança do mecanismo.
O resultado da criptografia inclui o conteúdo cifrado e informações de autenticação. Durante a descriptografia, o algoritmo verifica se os parâmetros e os dados correspondem àqueles usados originalmente. Se a verificação falhar, a operação retorna um erro em vez de apresentar a mensagem como se tivesse sido recuperada corretamente.
O pacote crypto/cipher fornece a interface AEAD, que disponibiliza os métodos Seal e Open. O método Seal criptografa e autentica os dados, enquanto Open verifica a autenticação antes de devolver o conteúdo original. Essa separação permite trabalhar com criptografia autenticada usando uma interface padronizada.
Criptografando uma mensagem com AES-GCM
Vamos criar um programa que gera uma chave AES-256, criptografa uma mensagem e depois recupera seu conteúdo. Para manter o exemplo simples, a chave será gerada durante a execução e usada imediatamente, sem ser gravada em um arquivo. Também geraremos um nonce aleatório com o tamanho esperado pela implementação de GCM.
O programa usará aes.NewCipher para criar uma cifra de bloco e cipher.NewGCM para associar o modo autenticado. Depois, chamaremos Seal para proteger os dados e Open para recuperá-los, verificando se a operação foi concluída corretamente.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
)
func main() {
// Gera uma chave AES-256 com 32 bytes.
chave := make([]byte, 32)
rand.Read(chave)
// Cria a cifra AES com a chave gerada.
bloco, err := aes.NewCipher(chave)
if err != nil {
fmt.Println("Erro ao criar AES:", err)
return
}
// Cria o modo autenticado GCM.
gcm, err := cipher.NewGCM(bloco)
if err != nil {
fmt.Println("Erro ao criar GCM:", err)
return
}
// Gera um nonce com o tamanho exigido pelo GCM.
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
// Define a mensagem que será protegida.
mensagem := []byte("Mensagem secreta")
// Criptografa e autentica os dados.
cifrado := gcm.Seal(
nil,
nonce,
mensagem,
nil,
)
// Armazena o nonce antes do conteúdo cifrado.
pacote := append(nonce, cifrado...)
// Representa o resultado em Base64.
texto := base64.StdEncoding.EncodeToString(
pacote,
)
fmt.Println("Criptografado:", texto)
// Recupera os bytes do pacote.
dados, err := base64.StdEncoding.DecodeString(
texto,
)
if err != nil {
fmt.Println("Erro ao decodificar:", err)
return
}
// Verifica se existe espaço suficiente para o nonce.
tamanhoNonce := gcm.NonceSize()
if len(dados) < tamanhoNonce+gcm.Overhead() {
fmt.Println("Conteúdo criptografado inválido.")
return
}
// Separa o nonce do conteúdo cifrado.
nonceRecebido := dados[:tamanhoNonce]
conteudo := dados[tamanhoNonce:]
// Verifica a autenticação e recupera a mensagem.
original, err := gcm.Open(
nil,
nonceRecebido,
conteudo,
nil,
)
if err != nil {
fmt.Println("Falha na autenticação:", err)
return
}
fmt.Println("Original:", string(original))
}
A função aes.NewCipher recebe a chave e cria uma implementação AES adequada ao tamanho informado. Nesse exemplo, usamos 32 bytes, correspondentes a AES-256. Depois, cipher.NewGCM cria uma implementação de criptografia autenticada que trabalha com essa cifra, fornecendo os métodos necessários para proteger e recuperar mensagens.
O nonce é gerado usando crypto/rand, com o tamanho retornado por gcm.NonceSize(). Na configuração padrão de GCM, esse tamanho corresponde a 12 bytes. O método Seal recebe o nonce e a mensagem, devolvendo os bytes criptografados junto às informações necessárias para verificar sua autenticidade.
Como o nonce não precisa ser secreto, podemos armazená-lo junto ao conteúdo cifrado. Nesse exemplo, colocamos seus bytes no início do pacote, permitindo que a operação de descriptografia saiba qual nonce foi usado. Depois, convertemos o pacote para Base64 apenas para apresentar o resultado em um formato textual.
Durante a recuperação, decodificamos o Base64 e verificamos se o pacote possui bytes suficientes para conter o nonce e os dados de autenticação. Depois, separamos as partes e chamamos gcm.Open, que verifica a autenticidade do conteúdo antes de devolver a mensagem original. Se os dados ou parâmetros não corresponderem aos valores esperados, a operação retornará um erro.
O resultado criptografado será diferente entre execuções porque geramos uma chave e um nonce novos. Entretanto, isso não significa que a mensagem original possa ser recuperada em outra execução, pois a chave gerada anteriormente não foi armazenada. Para proteger informações que precisam ser recuperadas posteriormente, precisamos implementar um mecanismo seguro para armazenar ou obter a chave correspondente.
Cuidados com chaves e nonces
Um dos cuidados mais importantes com AES-GCM é não reutilizar o mesmo nonce com a mesma chave. Essa reutilização pode comprometer tanto a confidencialidade quanto a integridade dos dados. Quando geramos nonces aleatórios, precisamos considerar a quantidade de mensagens criptografadas com a mesma chave e os limites recomendados pelo mecanismo escolhido.
Nas versões atuais do Go, também existe cipher.NewGCMWithRandomNonce, introduzido no Go 1.24, que administra a geração e o transporte do nonce aleatório. Essa API reduz a possibilidade de erros relacionados à construção manual dos pacotes, pois incorpora o nonce ao resultado criptografado. Para novas implementações compatíveis com essa versão, normalmente podemos preferir essa alternativa.
Também não devemos transformar uma senha comum diretamente em uma chave AES apenas convertendo seus caracteres para bytes. Senhas podem possuir pouca entropia e não necessariamente atendem ao tamanho exigido pelo algoritmo. Quando precisamos derivar uma chave a partir de uma senha, devemos usar uma função de derivação apropriada, com salt e parâmetros adequados ao objetivo.
O salt é um valor usado durante determinadas operações de derivação para impedir que entradas iguais produzam sempre os mesmos resultados. Ele normalmente não precisa ser secreto, mas deve ser gerado e administrado conforme as regras do mecanismo empregado. O salt não substitui a chave nem o nonce, porque cada um possui uma responsabilidade diferente.
Criptografia assimétrica com RSA ou ECDSA
A criptografia assimétrica trabalha com um par de chaves relacionadas matematicamente. Uma delas é pública e pode ser distribuída, enquanto a outra é privada e precisa permanecer protegida. Dependendo do algoritmo e da operação, esse par permite criptografar informações, verificar assinaturas ou participar de mecanismos de estabelecimento de chaves.
RSA é um algoritmo que pode ser usado tanto para criptografia assimétrica quanto para assinaturas digitais, desde que sejam escolhidos mecanismos apropriados para cada finalidade. ECDSA, por outro lado, é um algoritmo específico de assinatura digital, portanto não deve ser apresentado como uma alternativa para criptografar mensagens diretamente. Essa diferença é importante porque nem todos os algoritmos de chave pública oferecem as mesmas operações.
Em Go, o pacote crypto/rsa fornece recursos para trabalhar com RSA, enquanto crypto/ecdsa permite gerar e verificar assinaturas ECDSA. Para criptografia RSA, podemos usar OAEP, que é um mecanismo desenvolvido para proteger mensagens usando RSA. Para assinaturas RSA, podemos usar PSS, que possui uma finalidade diferente e será apresentado posteriormente.
Entendendo chaves públicas e privadas
Em um sistema de criptografia assimétrica, a chave pública pode ser disponibilizada para outros participantes, enquanto a chave privada deve permanecer sob controle de seu proprietário. Em uma operação de criptografia RSA-OAEP, podemos usar a chave pública do destinatário para proteger uma mensagem. Depois, somente quem possui a chave privada correspondente poderá executar a operação normal de descriptografia.
Entretanto, possuir uma chave pública não significa saber automaticamente a quem ela pertence. Um atacante poderia apresentar sua própria chave pública fingindo representar outro servidor ou usuário. Por isso, aplicações que dependem da identidade de um participante precisam verificar a associação entre a chave e a entidade correspondente, normalmente usando certificados ou outros mecanismos confiáveis.
Também não devemos confundir criptografia assimétrica com assinaturas digitais. Na criptografia, queremos proteger a confidencialidade do conteúdo, enquanto uma assinatura permite verificar sua autenticidade e integridade. Embora RSA possa ser usado para ambas as finalidades, os mecanismos e parâmetros envolvidos são diferentes e não devem ser misturados.
Gerando um par de chaves RSA
Podemos gerar um par de chaves RSA usando rsa.GenerateKey, que recebe uma fonte de aleatoriedade e o tamanho desejado da chave. O resultado é um valor do tipo rsa.PrivateKey, que contém a chave privada e permite acessar a chave pública correspondente. Como a geração envolve operações matemáticas mais complexas, ela pode demorar mais do que a criação de uma chave simétrica aleatória.
Vamos criar uma chave RSA de 2048 bits para demonstrar o funcionamento da API. Esse tamanho é usado aqui para fins didáticos, enquanto aplicações reais devem escolher parâmetros conforme seus requisitos de segurança e as recomendações atuais aplicáveis.
package main
import (
"crypto/rand"
"crypto/rsa"
"fmt"
)
func main() {
// Gera uma chave privada RSA de 2048 bits.
privada, err := rsa.GenerateKey(
rand.Reader,
2048,
)
if err != nil {
fmt.Println("Erro ao gerar chave:", err)
return
}
// Obtém a chave pública correspondente.
publica := &privada.PublicKey
fmt.Println("Chave privada gerada.")
fmt.Println("Tamanho da chave:", publica.N.BitLen())
}
A função rsa.GenerateKey usa uma fonte criptograficamente segura de aleatoriedade para gerar a chave privada. O campo PublicKey da estrutura contém os componentes necessários para representar a chave pública correspondente. Nesse exemplo, apenas apresentamos o tamanho da chave, evitando imprimir diretamente o material privado.
Quando precisamos manter uma chave entre diferentes execuções, podemos armazená-la em formatos apropriados, como PKCS#8 para chaves privadas. O pacote crypto/x509 possui funções para converter chaves em representações que podem ser gravadas em arquivos. Entretanto, devemos proteger o acesso ao arquivo e evitar armazenar chaves privadas em locais públicos ou repositórios de código-fonte.
Criptografando uma mensagem com RSA-OAEP
RSA-OAEP permite proteger pequenas mensagens usando uma chave pública RSA. O mecanismo combina RSA com procedimentos específicos de codificação e aleatoriedade, evitando trabalhar diretamente com operações matemáticas sobre a mensagem original. Em Go, podemos usar rsa.EncryptOAEP para criptografar e rsa.DecryptOAEP para recuperar o conteúdo usando a chave privada correspondente.
O mecanismo também usa uma função de hash, que precisa ser compatível entre as operações de criptografia e descriptografia. Podemos escolher SHA-256 usando sha256.New, além de uma fonte de aleatoriedade segura. Vamos criar um programa que gera o par de chaves, criptografa uma mensagem pequena e depois recupera o conteúdo.
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
)
func main() {
// Gera um par de chaves RSA.
privada, err := rsa.GenerateKey(
rand.Reader,
2048,
)
if err != nil {
fmt.Println("Erro ao gerar chave:", err)
return
}
// Define uma mensagem pequena.
mensagem := []byte("Mensagem protegida")
// Criptografa usando a chave pública.
cifrado, err := rsa.EncryptOAEP(
sha256.New(),
rand.Reader,
&privada.PublicKey,
mensagem,
nil,
)
if err != nil {
fmt.Println("Erro ao criptografar:", err)
return
}
// Recupera o conteúdo com a chave privada.
original, err := rsa.DecryptOAEP(
sha256.New(),
rand.Reader,
privada,
cifrado,
nil,
)
if err != nil {
fmt.Println("Erro ao descriptografar:", err)
return
}
fmt.Println("Original:", string(original))
}
A função rsa.EncryptOAEP recebe a função de hash, a fonte de aleatoriedade, a chave pública e os bytes que serão protegidos. O último argumento representa um label opcional, que permite associar determinado contexto à operação. Quando usamos um label, ele precisa corresponder ao valor informado durante a descriptografia para que a operação seja concluída.
A função rsa.DecryptOAEP recebe a chave privada e os bytes criptografados, verificando os parâmetros necessários para recuperar a mensagem. Como usamos SHA-256 nas duas operações, o mecanismo possui a mesma configuração de hash. Se os dados forem modificados ou a chave não corresponder ao conteúdo, a descriptografia deverá falhar.
RSA-OAEP não foi desenvolvido para criptografar arquivos grandes diretamente. O tamanho máximo da mensagem depende do tamanho da chave RSA e da função de hash escolhida. Com uma chave de 2048 bits e SHA-256, por exemplo, o limite é de 190 bytes para o conteúdo original, antes de considerar outros mecanismos que uma aplicação possa usar.
Para proteger grandes quantidades de dados, normalmente usamos criptografia híbrida. Nesse modelo, uma chave simétrica aleatória protege o conteúdo usando um algoritmo como AES-GCM, enquanto um mecanismo assimétrico protege ou estabelece a chave necessária para recuperar os dados. Isso combina as características dos dois modelos sem depender de RSA para processar diretamente todo o conteúdo.
Assinatura e verificação de dados
Assinaturas digitais permitem verificar se determinados dados foram assinados por quem possui uma chave privada correspondente. A operação de assinatura usa a chave privada, enquanto a verificação usa a chave pública. Isso permite distribuir informações assinadas e oferecer aos destinatários um mecanismo para verificar sua autenticidade e integridade.
Uma assinatura digital não criptografa automaticamente o conteúdo assinado. Os dados podem continuar disponíveis para leitura, porque a finalidade da assinatura é permitir verificar sua origem criptográfica e detectar alterações. Quando precisamos também proteger a confidencialidade, devemos aplicar criptografia por um mecanismo apropriado.
Normalmente, a assinatura é calculada sobre um digest da mensagem, e não diretamente sobre todos os seus bytes. Podemos usar SHA-256 para calcular esse digest e depois aplicar um algoritmo de assinatura, como RSA-PSS ou ECDSA. Durante a verificação, precisamos calcular novamente o hash dos dados recebidos e usar a chave pública correspondente para verificar a assinatura.
Assinando dados com RSA-PSS
RSA-PSS é um mecanismo de assinatura digital baseado em RSA. Ele foi desenvolvido para produzir assinaturas usando procedimentos específicos de codificação e aleatoriedade, sendo recomendado para novas aplicações que precisam trabalhar com assinaturas RSA. Em Go, podemos usar rsa.SignPSS para assinar um digest e rsa.VerifyPSS para verificar o resultado.
Vamos gerar um par de chaves RSA, calcular o SHA-256 de uma mensagem e produzir sua assinatura. Depois, usaremos a chave pública para verificar se a assinatura corresponde ao conteúdo original.
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
)
func main() {
// Gera um par de chaves RSA.
privada, err := rsa.GenerateKey(
rand.Reader,
2048,
)
if err != nil {
fmt.Println("Erro ao gerar chave:", err)
return
}
// Calcula o hash da mensagem.
mensagem := []byte("Documento original")
digest := sha256.Sum256(mensagem)
// Assina o digest usando RSA-PSS.
assinatura, err := rsa.SignPSS(
rand.Reader,
privada,
crypto.SHA256,
digest[:],
nil,
)
if err != nil {
fmt.Println("Erro ao assinar:", err)
return
}
// Verifica a assinatura com a chave pública.
err = rsa.VerifyPSS(
&privada.PublicKey,
crypto.SHA256,
digest[:],
assinatura,
nil,
)
if err != nil {
fmt.Println("Assinatura inválida:", err)
return
}
fmt.Println("Assinatura válida.")
}
A função sha256.Sum256 calcula o digest da mensagem, enquanto rsa.SignPSS usa a chave privada para produzir a assinatura correspondente. O argumento crypto.SHA256 informa qual função de hash foi usada para calcular o digest. Nesse exemplo, as opções específicas de PSS são deixadas como nil, permitindo usar os parâmetros padrão definidos pelo pacote.
A função rsa.VerifyPSS recebe a chave pública, o identificador do hash, o digest e a assinatura. Quando a verificação é concluída normalmente, ela retorna nil. Se a assinatura não corresponder aos dados e à chave informados, a função retorna um erro, permitindo rejeitar a informação recebida.
Também precisamos considerar que verificar uma assinatura demonstra a correspondência criptográfica com determinada chave pública. Isso não comprova sozinho a identidade civil ou organizacional de seu proprietário. Para associar a chave a uma identidade confiável, precisamos de mecanismos adicionais, como certificados digitais, distribuição segura de chaves ou um sistema de confiança definido pela aplicação.
Verificando alterações na mensagem
Uma assinatura digital deixa de corresponder à mensagem quando os dados usados durante a verificação são diferentes daqueles assinados originalmente. Isso permite identificar alterações no conteúdo, desde que a chave pública usada na verificação seja confiável. Podemos demonstrar esse comportamento modificando a mensagem depois de gerar uma assinatura.
O trecho seguinte representa a continuação de um programa que já possui a chave pública e a assinatura RSA-PSS produzida anteriormente. Vamos calcular o hash de um conteúdo modificado e tentar verificar a assinatura original usando esse novo digest.
// Modifica o conteúdo que será verificado.
alterada := []byte("Documento modificado")
// Calcula o hash da mensagem alterada.
novoDigest := sha256.Sum256(alterada)
// Tenta verificar a assinatura original.
err := rsa.VerifyPSS(
&privada.PublicKey,
crypto.SHA256,
novoDigest[:],
assinatura,
nil,
)
if err != nil {
fmt.Println("A assinatura não corresponde aos dados.")
}
Esse trecho pressupõe que os pacotes crypto, crypto/rsa, crypto/sha256 e fmt foram importados e que privada e assinatura já foram definidos. Como o digest da mensagem alterada é diferente daquele assinado originalmente, a verificação deverá falhar. Isso demonstra que a assinatura está associada ao conteúdo e não apenas à existência de determinado arquivo ou mensagem.
Também precisamos preservar corretamente os bytes usados na assinatura. Diferenças em espaços, quebras de linha, codificação de texto ou representação de dados estruturados podem alterar o digest, mesmo quando o conteúdo parece equivalente para uma pessoa. Em protocolos que assinam JSON ou outros formatos, normalmente precisamos definir regras claras para representar os dados antes da assinatura.
Assinando dados com ECDSA
ECDSA, que significa Elliptic Curve Digital Signature Algorithm, é um algoritmo de assinatura digital baseado em curvas elípticas. Ele também trabalha com um par de chaves pública e privada, mas usa operações matemáticas diferentes das utilizadas pelo RSA. Dependendo dos parâmetros escolhidos, pode oferecer características adequadas de segurança usando chaves menores.
Em Go, podemos gerar uma chave ECDSA usando ecdsa.GenerateKey e uma curva disponibilizada pelo pacote crypto/elliptic. Para assinaturas compatíveis com a representação ASN.1 DER, podemos usar ecdsa.SignASN1 e ecdsa.VerifyASN1, que permitem assinar e verificar um digest usando as chaves correspondentes.
Vamos criar um exemplo usando a curva P-256 e SHA-256. O programa gerará uma chave privada, calculará o digest da mensagem e verificará a assinatura produzida.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"fmt"
)
func main() {
// Gera um par de chaves ECDSA usando P-256.
privada, err := ecdsa.GenerateKey(
elliptic.P256(),
rand.Reader,
)
if err != nil {
fmt.Println("Erro ao gerar chave:", err)
return
}
// Calcula o digest da mensagem.
mensagem := []byte("Mensagem assinada")
digest := sha256.Sum256(mensagem)
// Produz uma assinatura ECDSA.
assinatura, err := ecdsa.SignASN1(
rand.Reader,
privada,
digest[:],
)
if err != nil {
fmt.Println("Erro ao assinar:", err)
return
}
// Verifica a assinatura com a chave pública.
valida := ecdsa.VerifyASN1(
&privada.PublicKey,
digest[:],
assinatura,
)
fmt.Println("Assinatura válida:", valida)
}
A função ecdsa.GenerateKey gera uma chave privada compatível com a curva P-256, enquanto ecdsa.SignASN1 produz a assinatura usando o digest calculado. O resultado é representado em um formato ASN.1 DER, que permite armazenar os componentes da assinatura seguindo uma estrutura padronizada. Essa representação pode ser transmitida ou armazenada conforme as necessidades da aplicação.
A função ecdsa.VerifyASN1 recebe a chave pública, o digest e a assinatura, retornando um booleano que indica se a verificação foi concluída corretamente. Diferentemente de rsa.VerifyPSS, que retorna um erro, essa função retorna true ou false. Portanto, precisamos considerar as características da API escolhida ao desenvolver funções que padronizam diferentes algoritmos de assinatura.
ECDSA não representa um mecanismo para criptografar diretamente a mensagem. Sua responsabilidade é produzir e verificar assinaturas digitais, enquanto outros algoritmos devem ser escolhidos quando precisamos proteger a confidencialidade dos dados. Essa separação evita confundir diferentes aplicações da criptografia assimétrica apenas porque elas trabalham com pares de chaves.
Introdução a TLS
TLS, que significa Transport Layer Security, é um protocolo usado para proteger a comunicação entre aplicações pela rede. Ele combina mecanismos criptográficos para estabelecer uma conexão que oferece confidencialidade, integridade e autenticação conforme a configuração adotada. TLS é amplamente usado em HTTPS, mas também pode proteger outros protocolos que trabalham sobre conexões TCP.
Quando um cliente estabelece uma conexão TLS, normalmente ocorre uma etapa de negociação chamada handshake. Durante esse processo, cliente e servidor negociam parâmetros criptográficos, estabelecem informações necessárias para proteger a comunicação e realizam verificações de identidade. Depois que o handshake é concluído, os dados da aplicação podem ser transmitidos por um canal protegido.
Em Go, o pacote crypto/tls fornece uma implementação de TLS que pode ser combinada com o pacote net. Isso permite criar clientes e servidores TCP protegidos sem precisar implementar manualmente os mecanismos de negociação de chaves ou criptografia dos dados. Nossa responsabilidade será configurar o certificado, os parâmetros permitidos e as regras de validação da conexão.
Entendendo o handshake TLS
O handshake TLS é a etapa inicial responsável por estabelecer os parâmetros de segurança usados durante a conexão. O cliente informa características compatíveis com sua implementação, enquanto o servidor participa da negociação e apresenta informações necessárias para sua autenticação. O processo também estabelece material criptográfico que será usado para proteger as mensagens transmitidas posteriormente.
Em TLS 1.3, o protocolo usa mecanismos modernos de estabelecimento de chaves e criptografia autenticada, evitando várias características antigas presentes em versões anteriores. O objetivo é permitir que os participantes obtenham chaves adequadas para a sessão sem transmitir essas chaves diretamente pela rede. Isso também permite oferecer propriedades como forward secrecy quando os mecanismos correspondentes são usados.
Forward secrecy significa que o comprometimento posterior de uma chave de identidade de longo prazo não deve permitir recuperar automaticamente o conteúdo de sessões anteriores, desde que as condições de segurança do protocolo tenham sido atendidas. Essa propriedade depende do mecanismo de estabelecimento das chaves de sessão, não apenas do algoritmo usado para criptografar os dados.
Certificados digitais
Um certificado digital é uma estrutura que associa uma chave pública a informações sobre uma identidade. Em TLS, certificados X.509 são usados principalmente para permitir que o cliente verifique a identidade apresentada pelo servidor. Esses certificados também contêm informações como período de validade, nomes autorizados e dados relacionados à entidade responsável pela assinatura.
Normalmente, o certificado do servidor é assinado por uma autoridade certificadora, também conhecida como CA. Uma CA é uma entidade responsável por emitir ou assinar certificados seguindo determinadas regras de confiança. O cliente verifica se consegue construir uma cadeia válida até uma autoridade em que confia, além de conferir se o certificado corresponde ao destino acessado.
Certificados também podem ser criados e assinados localmente para testes. Nesse caso, o cliente não confiará automaticamente neles, porque a autoridade usada para emitir o certificado não faz parte necessariamente de seu conjunto de autoridades confiáveis. Para realizar testes adequadamente, podemos informar explicitamente qual certificado de CA deve ser aceito pelo cliente.
A chave privada correspondente ao certificado do servidor precisa permanecer protegida. Ela permite demonstrar a posse da chave associada à identidade apresentada e participa dos mecanismos de autenticação do handshake. Se essa chave for comprometida, a identidade do servidor poderá ser falsificada em determinadas condições, exigindo a substituição do material criptográfico.
Versões do protocolo TLS
TLS possui diferentes versões, mas nem todas são adequadas para novas aplicações. TLS 1.0 e TLS 1.1 são versões antigas que não devem ser escolhidas para novos serviços. Nas implementações atuais do Go, normalmente trabalhamos com TLS 1.2 e TLS 1.3, mantendo os parâmetros compatíveis com as necessidades do ambiente.
O pacote crypto/tls permite configurar a versão mínima aceita usando o campo MinVersion de tls.Config. Isso impede que a aplicação negocie versões anteriores ao limite definido. Podemos usar a constante tls.VersionTLS12 quando precisamos exigir pelo menos TLS 1.2.
// Define a versão mínima permitida.
config := &tls.Config{
MinVersion: tls.VersionTLS12,
}
Esse trecho pressupõe que o pacote crypto/tls foi importado. A estrutura tls.Config possui outros campos necessários para determinadas operações, como os certificados apresentados pelo servidor e as autoridades confiáveis usadas pelo cliente. O exemplo mostra apenas a definição da versão mínima, portanto não representa sozinho uma configuração completa para estabelecer uma conexão.
Também não precisamos configurar manualmente todas as cifras permitidas quando usamos as configurações seguras disponibilizadas pela biblioteca. O pacote crypto/tls já administra diferentes aspectos da negociação, incluindo conjuntos criptográficos compatíveis com as versões suportadas. Alterações manuais nessas opções devem acontecer somente quando existe uma necessidade específica e seus efeitos são compreendidos.
Criando cliente TCP com TLS
Um cliente TCP com TLS estabelece uma conexão com um servidor e negocia uma camada de proteção antes de transmitir os dados da aplicação. Depois que o handshake termina, podemos trabalhar com métodos de leitura e escrita semelhantes aos usados em conexões TCP comuns. A diferença é que o pacote crypto/tls fica responsável por proteger os dados transmitidos.
Em Go, podemos usar tls.DialWithDialer para estabelecer uma conexão TLS com configurações de rede e limites de tempo. A função recebe um net.Dialer, o tipo de rede, o endereço de destino e uma estrutura tls.Config. Quando a operação termina com sucesso, recebemos um objeto *tls.Conn, que representa a conexão protegida.
Antes de criar nosso servidor local, vamos entender como um cliente pode se conectar a um serviço HTTPS público usando TLS. Esse exemplo demonstra a negociação e a verificação do certificado, mas não envia uma requisição HTTP. O objetivo é trabalhar diretamente com a conexão TLS e observar os parâmetros negociados.
Estabelecendo uma conexão TLS
Podemos criar um cliente que estabelece uma conexão com example.com na porta 443, normalmente usada por serviços HTTPS. O pacote crypto/tls consultará as autoridades confiáveis configuradas para o ambiente e verificará o certificado apresentado pelo servidor. Quando a conexão for estabelecida, poderemos consultar informações sobre a sessão negociada.
Vamos usar net.Dialer para estabelecer um timeout e tls.Config para definir a versão mínima do protocolo. Também informaremos ServerName, responsável por identificar o nome que deve corresponder ao certificado apresentado pelo destino.
package main
import (
"crypto/tls"
"fmt"
"net"
"os"
"time"
)
func main() {
// Configura o tempo máximo de conexão.
dialer := &net.Dialer{
Timeout: 5 * time.Second,
}
// Define os parâmetros da conexão TLS.
config := &tls.Config{
ServerName: "example.com",
MinVersion: tls.VersionTLS12,
}
// Estabelece a conexão TCP protegida por TLS.
conn, err := tls.DialWithDialer(
dialer,
"tcp",
"example.com:443",
config,
)
if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro na conexão TLS:",
err,
)
return
}
defer conn.Close()
// Consulta informações da sessão negociada.
estado := conn.ConnectionState()
fmt.Println("Conexão TLS estabelecida.")
fmt.Println("Versão:", tls.VersionName(estado.Version))
fmt.Println("Servidor:", estado.ServerName)
}
A função tls.DialWithDialer estabelece a conexão e realiza o handshake TLS antes de retornar normalmente. Durante esse processo, a implementação verifica o certificado apresentado pelo servidor, considerando o nome esperado e as autoridades confiáveis disponíveis. Se essa verificação falhar, a conexão retorna um erro em vez de continuar como se o servidor tivesse sido autenticado.
O campo ServerName define o nome usado durante a verificação do certificado e, quando adequado, na extensão SNI enviada durante o handshake. SNI permite informar o nome do servidor que o cliente pretende acessar, o que ajuda serviços que disponibilizam diferentes certificados no mesmo endereço IP. Esse campo não deve ser confundido apenas com o endereço usado para estabelecer a conexão TCP.
O método ConnectionState retorna informações sobre a sessão TLS, incluindo a versão negociada e outros parâmetros. A função tls.VersionName converte o identificador da versão para uma representação textual, permitindo apresentar valores como TLS 1.2 ou TLS 1.3. O resultado depende das versões suportadas pelo cliente e pelo servidor.
Esse exemplo não transmite dados de aplicação depois do handshake. Uma conexão TLS estabelecida indica que a negociação de segurança foi concluída, mas não comprova que o serviço respondeu corretamente a uma requisição HTTP ou outro protocolo. Para validar o comportamento de uma aplicação, precisamos enviar uma mensagem adequada e interpretar a resposta recebida.
Criando servidor TCP com TLS
Um servidor TCP com TLS recebe conexões e executa o handshake necessário para estabelecer um canal protegido com cada cliente. Para isso, precisa apresentar um certificado adequado e demonstrar a posse da chave privada correspondente. Depois da negociação, o servidor pode receber e enviar dados usando operações semelhantes às de uma conexão TCP comum.
Em Go, podemos usar tls.Listen para criar um listener TLS. A função recebe o tipo de rede, o endereço local e uma estrutura tls.Config contendo os certificados e parâmetros necessários. As conexões aceitas podem ser trabalhadas por goroutines, permitindo atender vários clientes durante o mesmo período.
Para testar essa comunicação localmente, precisamos primeiro gerar um certificado e uma chave privada. Como nosso cliente deverá validar a identidade do servidor, também criaremos uma autoridade certificadora local que assinará o certificado de teste. Isso permitirá verificar a cadeia de confiança sem desativar as validações normalmente realizadas pelo TLS.
Organizando os arquivos de teste
Vamos criar um pequeno projeto com três programas separados. O primeiro será responsável por gerar a autoridade certificadora e o certificado do servidor, enquanto os outros dois implementarão a comunicação TCP com TLS. Essa separação evita misturar a geração de material criptográfico com a execução normal dos programas de rede.
A estrutura do projeto ficará semelhante a esta depois da geração dos certificados:
tls-local/
├── go.mod
├── gerador/
│ └── main.go
├── servidor/
│ └── main.go
├── cliente/
│ └── main.go
├── ca.pem
├── servidor.pem
└── servidor-chave.pem
O arquivo ca.pem conterá o certificado público da autoridade local, enquanto servidor.pem armazenará o certificado apresentado pelo servidor. Já servidor-chave.pem conterá a chave privada correspondente, que deverá ser protegida contra acesso não autorizado. A chave privada da CA será usada apenas durante a geração e não será gravada neste exemplo.
Podemos criar o módulo Go na raiz do projeto usando o comando:
go mod init exemplo/tls-local
Como os programas usam apenas recursos da biblioteca padrão, não precisamos adicionar dependências externas. Entretanto, os comandos de execução pressupõem que estamos trabalhando a partir da raiz do projeto, porque os caminhos dos certificados serão interpretados em relação ao diretório de trabalho atual.
Gerando uma CA e um certificado local
Vamos criar uma autoridade certificadora local e usá-la para assinar o certificado do servidor. Esse processo envolve gerar dois pares de chaves, criar um certificado para a CA e emitir outro certificado associado ao endereço local. O certificado do servidor terá localhost como nome DNS e 127.0.0.1 como endereço IP autorizado, permitindo que o cliente valide ambos corretamente.
O pacote crypto/x509 fornece CreateCertificate, que permite criar certificados X.509 a partir de estruturas que definem seus campos. Também usaremos crypto/ecdsa para gerar as chaves privadas e encoding/pem para armazenar os certificados em um formato textual. Os arquivos serão criados somente quando não existirem, evitando sobrescrever material gerado anteriormente.
No arquivo gerador/main.go, podemos escrever o seguinte programa:
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)
func gravarPEM(
caminho string,
tipo string,
dados []byte,
permissao os.FileMode,
) error {
// Cria o arquivo somente se ele não existir.
arquivo, err := os.OpenFile(
caminho,
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
permissao,
)
if err != nil {
return err
}
// Converte os bytes para o formato PEM.
erroEscrita := pem.Encode(
arquivo,
&pem.Block{
Type: tipo,
Bytes: dados,
},
)
// Verifica também o fechamento do arquivo.
erroFechamento := arquivo.Close()
if erroEscrita != nil {
return erroEscrita
}
return erroFechamento
}
func executar() error {
// Define o período de validade dos certificados.
inicio := time.Now().Add(-5 * time.Minute)
fim := time.Now().Add(24 * time.Hour)
// Gera a chave privada da autoridade certificadora.
chaveCA, err := ecdsa.GenerateKey(
elliptic.P256(),
rand.Reader,
)
if err != nil {
return fmt.Errorf("gerar chave da CA: %w", err)
}
// Define o certificado da autoridade local.
modeloCA := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "CA local de teste",
},
NotBefore: inicio,
NotAfter: fim,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign |
x509.KeyUsageCRLSign,
}
// Cria um certificado autoassinado para a CA.
bytesCA, err := x509.CreateCertificate(
rand.Reader,
modeloCA,
modeloCA,
&chaveCA.PublicKey,
chaveCA,
)
if err != nil {
return fmt.Errorf("criar certificado da CA: %w", err)
}
// Interpreta a CA que será usada para assinar o servidor.
certificadoCA, err := x509.ParseCertificate(bytesCA)
if err != nil {
return fmt.Errorf("interpretar CA: %w", err)
}
// Gera a chave privada do servidor.
chaveServidor, err := ecdsa.GenerateKey(
elliptic.P256(),
rand.Reader,
)
if err != nil {
return fmt.Errorf("gerar chave do servidor: %w", err)
}
// Define os nomes aceitos pelo certificado do servidor.
modeloServidor := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
CommonName: "servidor-local",
},
NotBefore: inicio,
NotAfter: fim,
DNSNames: []string{
"localhost",
},
IPAddresses: []net.IP{
net.ParseIP("127.0.0.1"),
},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
BasicConstraintsValid: true,
}
// Assina o certificado do servidor usando a CA.
bytesServidor, err := x509.CreateCertificate(
rand.Reader,
modeloServidor,
certificadoCA,
&chaveServidor.PublicKey,
chaveCA,
)
if err != nil {
return fmt.Errorf(
"criar certificado do servidor: %w",
err,
)
}
// Converte a chave privada para PKCS#8.
bytesChave, err := x509.MarshalPKCS8PrivateKey(
chaveServidor,
)
if err != nil {
return fmt.Errorf(
"converter chave privada: %w",
err,
)
}
// Grava o certificado público da CA.
if err := gravarPEM(
"ca.pem",
"CERTIFICATE",
bytesCA,
0644,
); err != nil {
return fmt.Errorf("gravar ca.pem: %w", err)
}
// Grava o certificado público do servidor.
if err := gravarPEM(
"servidor.pem",
"CERTIFICATE",
bytesServidor,
0644,
); err != nil {
return fmt.Errorf("gravar servidor.pem: %w", err)
}
// Grava a chave privada com acesso restrito.
if err := gravarPEM(
"servidor-chave.pem",
"PRIVATE KEY",
bytesChave,
0600,
); err != nil {
return fmt.Errorf(
"gravar servidor-chave.pem: %w",
err,
)
}
return nil
}
func main() {
if err := executar(); err != nil {
fmt.Fprintln(os.Stderr, "Erro:", err)
os.Exit(1)
}
fmt.Println("Certificados de teste criados.")
}
O programa gera uma chave privada ECDSA para a autoridade certificadora e cria um certificado que permite assinar outros certificados. O campo IsCA identifica sua responsabilidade como autoridade, enquanto KeyUsageCertSign permite declarar que aquela chave pode ser usada para assinar certificados. Como a CA é criada usando o próprio certificado como emissor, seu certificado é autoassinado.
Depois, o programa gera outra chave privada para o servidor e cria um certificado assinado pela CA. Os campos DNSNames e IPAddresses indicam os nomes e endereços que poderão ser verificados pelo cliente durante o handshake. Já ExtKeyUsageServerAuth identifica que o certificado foi emitido para autenticação de servidor.
A função x509.CreateCertificate recebe o modelo do certificado, o certificado emissor, a chave pública do titular e a chave privada usada para assinar. No caso do certificado do servidor, a assinatura é produzida pela chave privada da CA. Isso permite que um cliente que confia na CA verifique a cadeia de certificação apresentada pelo servidor.
A função x509.MarshalPKCS8PrivateKey converte a chave privada do servidor para uma representação DER no formato PKCS#8. Depois, usamos PEM para armazenar esses bytes em um arquivo textual delimitado por identificadores próprios. Esse arquivo continuará contendo material secreto, porque PEM é uma representação de dados e não fornece criptografia automaticamente.
A função gravarPEM cria os arquivos usando os.O_EXCL, impedindo que um arquivo existente seja sobrescrito. Entretanto, o processo de geração não é transacional, portanto uma falha depois de criar alguns arquivos pode deixar o conjunto incompleto. Para repetir o procedimento, devemos trabalhar em um diretório de teste limpo, sem misturar certificados gerados em execuções diferentes.
Os números de série fixos e o prazo de 24 horas foram escolhidos apenas para simplificar o exemplo local. Em uma infraestrutura real de certificação, os números de série devem ser únicos conforme as regras aplicáveis, e a emissão precisa seguir políticas próprias de validade, armazenamento e administração das chaves. Também não devemos usar uma CA criada apenas para testes como autoridade confiável de sistemas reais.
Executando a geração dos certificados
Depois de criar o programa, podemos executá-lo a partir da raiz do projeto usando go run. Os arquivos serão gravados no diretório de trabalho atual, portanto o comando deve ser executado no local em que esperamos encontrar ca.pem, servidor.pem e servidor-chave.pem.
go run ./gerador
Se a operação for concluída normalmente, teremos a mensagem:
Certificados de teste criados.
O arquivo ca.pem poderá ser distribuído ao cliente de teste para estabelecer confiança na autoridade local. Já servidor.pem será apresentado durante o handshake TLS, enquanto servidor-chave.pem deverá permanecer disponível somente para o servidor. Não devemos distribuir essa chave privada junto ao certificado público, porque ela representa um segredo necessário para autenticar a identidade do serviço.
Em sistemas Unix, as permissões 0600 solicitadas durante a criação restringem a leitura e a escrita da chave privada ao proprietário, considerando as regras do sistema de arquivos. No Windows, as permissões seguem mecanismos diferentes, por isso precisamos verificar a proteção efetiva do arquivo no ambiente usado. Essas permissões não substituem controles adequados de armazenamento de segredos.
Criando o servidor TLS
Depois de gerar os certificados, podemos criar um servidor que escuta conexões TCP protegidas por TLS. O pacote crypto/tls possui a função LoadX509KeyPair, responsável por carregar o certificado do servidor e sua chave privada a partir dos arquivos PEM. O resultado pode ser informado em tls.Config, permitindo que o listener apresente esse certificado durante o handshake.
Vamos criar um servidor simples que recebe uma linha de texto e responde com PONG quando o cliente envia PING. Cada conexão será atendida em uma goroutine, permitindo que o listener continue recebendo novos clientes. Também configuraremos um deadline para impedir que um cliente mantenha sua conexão bloqueada indefinidamente.
No arquivo servidor/main.go, podemos escrever:
package main
import (
"bufio"
"crypto/tls"
"fmt"
"log"
"net"
"time"
)
func atender(conn net.Conn) {
// Fecha a conexão quando o atendimento terminar.
defer conn.Close()
// Limita o tempo de handshake e comunicação.
if err := conn.SetDeadline(
time.Now().Add(10 * time.Second),
); err != nil {
log.Println("Erro no deadline:", err)
return
}
// Realiza explicitamente o handshake TLS.
conexaoTLS, ok := conn.(*tls.Conn)
if !ok {
log.Println("Conexão não é TLS.")
return
}
if err := conexaoTLS.Handshake(); err != nil {
log.Println("Erro no handshake:", err)
return
}
// Lê uma linha enviada pelo cliente.
leitor := bufio.NewReader(conexaoTLS)
mensagem, err := leitor.ReadString('\n')
if err != nil {
log.Println("Erro ao ler mensagem:", err)
return
}
if mensagem != "PING\n" {
fmt.Fprintln(conexaoTLS, "COMANDO_INVALIDO")
return
}
// Responde pela conexão protegida.
if _, err := fmt.Fprintln(
conexaoTLS,
"PONG",
); err != nil {
log.Println("Erro ao responder:", err)
}
}
func main() {
// Carrega o certificado e a chave privada do servidor.
certificado, err := tls.LoadX509KeyPair(
"servidor.pem",
"servidor-chave.pem",
)
if err != nil {
log.Fatal("Erro ao carregar certificado:", err)
}
// Define os parâmetros TLS aceitos pelo servidor.
config := &tls.Config{
Certificates: []tls.Certificate{
certificado,
},
MinVersion: tls.VersionTLS12,
}
// Cria um listener TCP protegido por TLS.
listener, err := tls.Listen(
"tcp",
"127.0.0.1:9443",
config,
)
if err != nil {
log.Fatal("Erro ao iniciar servidor:", err)
}
defer listener.Close()
log.Println("Servidor TLS em 127.0.0.1:9443")
for {
// Aguarda uma nova conexão.
conn, err := listener.Accept()
if err != nil {
log.Println("Erro ao aceitar:", err)
return
}
// Atende o cliente concorrentemente.
go atender(conn)
}
}
A função tls.LoadX509KeyPair carrega o certificado e a chave privada, verificando se os arquivos podem ser interpretados como um par compatível. Depois, a estrutura tls.Config recebe o certificado e define a versão mínima do protocolo. Isso permite que o listener apresente a identidade configurada aos clientes durante a negociação TLS.
A função tls.Listen cria um listener que recebe conexões TCP e as associa ao mecanismo TLS. Quando chamamos Accept, recebemos uma conexão que poderá executar o handshake TLS durante suas operações. Nesse exemplo, fazemos uma conversão para *tls.Conn e chamamos Handshake explicitamente, permitindo identificar falhas de negociação antes de processar mensagens da aplicação.
Depois do handshake, o servidor lê uma linha usando bufio.Reader. Se a mensagem recebida corresponder a PING\n, ele devolve PONG, acrescentando uma quebra de linha ao final da resposta. O cliente poderá usar a mesma regra para identificar quando a mensagem foi recebida completamente.
O deadline de dez segundos limita o período disponível para as operações realizadas na conexão. Entretanto, ele é estabelecido como um instante único, portanto também continua valendo durante a leitura e a escrita posteriores ao handshake. Em servidores que mantêm conexões abertas por mais tempo, normalmente precisamos atualizar os deadlines conforme o comportamento do protocolo de aplicação.
Esse servidor foi desenvolvido para testes locais e não implementa um mecanismo completo de encerramento controlado ou limite de clientes simultâneos. Também não exige certificados dos clientes, porque o objetivo é demonstrar a autenticação do servidor. Quando precisamos autenticar os dois lados da conexão usando certificados, podemos configurar mTLS, que possui regras adicionais de emissão e validação.
Executando o servidor
Depois que os certificados foram gerados, podemos iniciar o servidor a partir da raiz do projeto. Como os caminhos são relativos, precisamos manter o diretório de trabalho no mesmo local em que estão os arquivos PEM.
go run ./servidor
Se os arquivos forem carregados corretamente e a porta estiver disponível, o programa apresentará uma mensagem semelhante a esta:
Servidor TLS em 127.0.0.1:9443
O servidor ficará aguardando conexões TCP na porta 9443. Entretanto, clientes que tentarem transmitir dados diretamente pela conexão sem executar o handshake TLS não conseguirão utilizar corretamente o protocolo esperado. Isso acontece porque o servidor exige que a comunicação seja negociada e protegida antes de processar as mensagens de aplicação.
Validando certificados
A validação de certificados é uma etapa fundamental da autenticação TLS. Quando um servidor apresenta um certificado, o cliente precisa verificar se ele corresponde ao destino acessado e se sua cadeia de certificação termina em uma autoridade confiável. Também precisam ser consideradas informações como período de validade, usos permitidos e outras restrições presentes nos certificados.
Sem essas verificações, uma conexão poderia ser criptografada com um servidor diferente daquele que pretendíamos acessar. Isso acontece porque criptografia e autenticação são propriedades relacionadas, mas distintas. Mesmo um atacante pode estabelecer uma conexão criptografada usando seu próprio certificado, por isso precisamos verificar se a identidade apresentada corresponde ao destino esperado.
Em Go, o pacote crypto/tls executa as verificações normais de certificados durante o handshake do cliente, usando os mecanismos disponíveis em crypto/x509. Para certificados emitidos por autoridades públicas reconhecidas pelo ambiente, normalmente podemos usar o conjunto de raízes confiáveis do sistema. Para nossa CA local, precisaremos informar explicitamente que seu certificado é confiável para os testes.
Criando o cliente TLS com validação
Vamos criar um cliente que carrega ca.pem, estabelece uma conexão com o servidor local e verifica sua identidade durante o handshake. O certificado da CA será adicionado a um CertPool, que representa um conjunto de certificados confiáveis para a validação. Depois, usaremos esse conjunto no campo RootCAs de tls.Config.
O cliente também informará ServerName: "localhost", que corresponde ao nome DNS presente no certificado emitido anteriormente. Embora a conexão TCP seja estabelecida com 127.0.0.1, a validação verificará o nome configurado, permitindo demonstrar que o endereço da conexão e a identidade esperada são informações relacionadas, mas não obrigatoriamente iguais.
No arquivo cliente/main.go, podemos escrever:
package main
import (
"bufio"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"os"
"time"
)
func executar() error {
// Lê o certificado da CA local.
certificadoCA, err := os.ReadFile("ca.pem")
if err != nil {
return fmt.Errorf("ler certificado da CA: %w", err)
}
// Cria um conjunto de autoridades confiáveis.
raizes := x509.NewCertPool()
// Adiciona a CA local ao conjunto.
if !raizes.AppendCertsFromPEM(certificadoCA) {
return fmt.Errorf("certificado da CA inválido")
}
// Define os parâmetros de validação TLS.
config := &tls.Config{
RootCAs: raizes,
ServerName: "localhost",
MinVersion: tls.VersionTLS12,
}
// Configura o tempo máximo de conexão.
dialer := &net.Dialer{
Timeout: 5 * time.Second,
}
// Estabelece a conexão TLS e valida o servidor.
conn, err := tls.DialWithDialer(
dialer,
"tcp",
"127.0.0.1:9443",
config,
)
if err != nil {
return fmt.Errorf("conexão TLS: %w", err)
}
defer conn.Close()
// Limita a comunicação depois do handshake.
if err := conn.SetDeadline(
time.Now().Add(5 * time.Second),
); err != nil {
return err
}
// Envia uma mensagem pela conexão protegida.
if _, err := fmt.Fprintln(
conn,
"PING",
); err != nil {
return fmt.Errorf("enviar mensagem: %w", err)
}
// Aguarda a resposta enviada pelo servidor.
leitor := bufio.NewReader(conn)
resposta, err := leitor.ReadString('\n')
if err != nil {
return fmt.Errorf("receber resposta: %w", err)
}
// Consulta a versão negociada na sessão TLS.
estado := conn.ConnectionState()
fmt.Println(
"Versão TLS:",
tls.VersionName(estado.Version),
)
fmt.Print("Resposta: ", resposta)
return nil
}
func main() {
if err := executar(); err != nil {
fmt.Fprintln(os.Stderr, "Erro:", err)
os.Exit(1)
}
}
A função os.ReadFile carrega o certificado público da CA local, enquanto x509.NewCertPool cria um conjunto inicialmente vazio de autoridades confiáveis. O método AppendCertsFromPEM interpreta o conteúdo PEM e adiciona os certificados encontrados. Quando nenhum certificado válido é identificado, o programa retorna um erro antes de tentar estabelecer a conexão.
A estrutura tls.Config usa RootCAs para informar quais autoridades poderão ser usadas durante a verificação do certificado apresentado pelo servidor. Nesse exemplo, criamos um conjunto contendo somente nossa CA de teste, evitando acrescentar automaticamente outras autoridades. O campo ServerName define a identidade esperada, enquanto MinVersion estabelece a versão mínima do protocolo.
A função tls.DialWithDialer estabelece a conexão e executa o handshake, incluindo a validação da cadeia de certificados e do nome informado. Quando essa verificação termina normalmente, podemos enviar dados usando fmt.Fprintln e receber a resposta com bufio.Reader. Os dados são transmitidos pela conexão TLS, sem que o código da aplicação precise criptografar manualmente cada mensagem.
Depois da resposta, usamos ConnectionState para consultar a versão TLS negociada. O programa também mantém um deadline de cinco segundos para as operações posteriores ao handshake, evitando permanecer aguardando indefinidamente quando o servidor não responde. A conexão é fechada quando a função termina, liberando os recursos associados ao atendimento.
Testando a comunicação TLS
Com o servidor executando em um terminal, podemos iniciar o cliente em outro terminal. Como os certificados foram gerados para a máquina local, não precisamos disponibilizar a porta para outras interfaces de rede. O cliente carregará a CA, realizará o handshake e enviará a mensagem PING depois que a validação terminar normalmente.
go run ./cliente
Se a comunicação for concluída, teremos uma saída semelhante a esta:
Versão TLS: TLS 1.3
Resposta: PONG
A versão apresentada depende da negociação realizada entre cliente e servidor. Quando ambos oferecem suporte a TLS 1.3 e não existem restrições adicionais, essa versão poderá ser selecionada. Entretanto, o programa também permite TLS 1.2, porque configuramos essa versão como limite mínimo.
O recebimento de PONG confirma que o cliente conseguiu estabelecer a conexão protegida, enviar a mensagem e receber a resposta esperada. Nesse caso, também sabemos que o handshake concluiu as verificações de certificado configuradas. Isso é diferente de uma conexão TCP comum, na qual o estabelecimento do socket não fornece automaticamente autenticação da identidade do servidor.
Validando o nome do certificado
O certificado emitido anteriormente possui localhost em DNSNames e 127.0.0.1 em IPAddresses. Esses campos representam os nomes e endereços que podem ser aceitos durante a verificação de identidade. Em certificados modernos, essa informação é registrada na extensão Subject Alternative Name, normalmente chamada de SAN.
O campo Common Name do certificado não deve ser usado como substituto para os nomes presentes em SAN. As implementações atuais do Go verificam os nomes e endereços declarados nas extensões apropriadas, por isso precisamos incluir corretamente os destinos que deverão ser aceitos. Isso evita depender de comportamentos antigos de validação de certificados.
Podemos demonstrar uma falha de identidade modificando temporariamente o campo ServerName do cliente para um nome que não aparece no certificado.
// Nome que não corresponde ao certificado apresentado.
config := &tls.Config{
RootCAs: raizes,
ServerName: "outro.local",
MinVersion: tls.VersionTLS12,
}
Esse trecho pressupõe que raizes já contém o certificado da CA local e que o pacote crypto/tls foi importado. Se tentarmos estabelecer a conexão usando esse nome, a validação deverá falhar porque o certificado do servidor não foi emitido para outro.local. Isso demonstra que confiar na autoridade emissora não é suficiente, pois também precisamos verificar a identidade do destino.
A mesma ideia vale para endereços IP. Quando o cliente verifica uma conexão usando um endereço IP, esse valor precisa aparecer como IP SAN no certificado. Colocar o endereço apenas em DNSNames ou no Common Name não representa uma configuração equivalente. Para evitar erros, precisamos gerar certificados contendo os nomes e endereços realmente usados pelos clientes.
Usando certificados confiáveis do sistema
Em aplicações que consultam serviços públicos, normalmente queremos aproveitar as autoridades certificadoras já confiáveis para o sistema operacional. Go permite obter esse conjunto usando x509.SystemCertPool, que retorna um conjunto de raízes disponibilizado pelo ambiente. Isso evita precisar manter manualmente uma lista de certificados para os serviços HTTPS comuns.
Também podemos acrescentar uma CA privada a esse conjunto, permitindo que a aplicação confie nas autoridades do sistema e em uma autoridade específica usada pela organização. Entretanto, essa operação deve acontecer somente quando a CA adicional for confiável, porque adicioná-la amplia o conjunto de autoridades capazes de validar certificados.
// Obtém as autoridades confiáveis do sistema.
raizes, err := x509.SystemCertPool()
if err != nil {
return fmt.Errorf(
"carregar autoridades do sistema: %w",
err,
)
}
// Lê o certificado da CA adicional.
dados, err := os.ReadFile("ca.pem")
if err != nil {
return fmt.Errorf("ler CA adicional: %w", err)
}
// Acrescenta a CA ao conjunto existente.
if !raizes.AppendCertsFromPEM(dados) {
return fmt.Errorf("certificado da CA inválido")
}
Esse trecho pressupõe que os pacotes crypto/x509, fmt e os foram importados e que o código está dentro de uma função que retorna error. A função SystemCertPool obtém uma cópia do conjunto de autoridades disponíveis, enquanto AppendCertsFromPEM acrescenta o certificado informado. Alterar esse objeto não significa modificar permanentemente o armazenamento de certificados do sistema operacional.
Também precisamos considerar que a disponibilidade e o conteúdo das autoridades confiáveis variam entre plataformas. Alguns sistemas usam mecanismos próprios de validação e armazenamento, enquanto outros dependem de arquivos ou diretórios de certificados. Por isso, aplicações que precisam funcionar em diferentes ambientes normalmente devem permitir configurar autoridades adicionais sem assumir que todos os sistemas possuem exatamente os mesmos certificados instalados.
Por que não usar InsecureSkipVerify
O campo InsecureSkipVerify de tls.Config permite desativar as verificações normais da cadeia de certificados e do nome do servidor quando configurado como true. Embora isso possa fazer uma conexão funcionar diante de determinados problemas de certificado, também remove proteções fundamentais da autenticação TLS. Dessa maneira, um atacante capaz de interceptar a comunicação pode conseguir apresentar um certificado que não deveria ser aceito.
Por isso, não devemos usar InsecureSkipVerify: true como solução para certificados autoassinados ou problemas de configuração. Quando trabalhamos com uma CA privada, a abordagem adequada é adicioná-la ao conjunto de autoridades confiáveis e manter a validação habilitada. Também precisamos garantir que o nome usado pelo cliente corresponda às informações presentes no certificado.
Existem aplicações avançadas que implementam verificações próprias usando mecanismos como VerifyConnection, mas isso exige compreender completamente as garantias que serão substituídas ou complementadas. Para ferramentas comuns e clientes de rede, devemos manter as verificações padrão e corrigir a causa dos erros de certificado, em vez de ignorá-los.
Evitando criar criptografia própria sem necessidade
Implementar algoritmos criptográficos exige conhecimento matemático e atenção a detalhes que podem não ser evidentes durante testes comuns. Um programa pode criptografar e descriptografar mensagens corretamente e ainda possuir falhas que comprometem a confidencialidade ou a integridade dos dados. Por isso, normalmente devemos usar implementações conhecidas e mecanismos padronizados, em vez de desenvolver algoritmos próprios.
A biblioteca padrão do Go já fornece diferentes recursos para trabalhar com criptografia, incluindo funções de hash, criptografia simétrica, assinaturas digitais e TLS. Isso permite construir aplicações seguras sem precisar implementar diretamente operações matemáticas complexas. Nossa responsabilidade continua sendo escolher os mecanismos adequados, configurar seus parâmetros corretamente e proteger as chaves usadas.
Também precisamos distinguir implementar um algoritmo de criar uma aplicação que usa criptografia. Desenvolver um cliente TLS usando crypto/tls não significa criar um protocolo criptográfico próprio, porque estamos usando uma implementação existente. Entretanto, inventar um formato de negociação de chaves ou combinar algoritmos sem considerar suas propriedades pode introduzir problemas difíceis de identificar.
Erros comuns ao trabalhar com criptografia
Um erro comum é considerar que qualquer transformação de dados fornece confidencialidade. Base64 e hexadecimal apenas modificam a representação dos bytes, enquanto hashes não permitem recuperar diretamente a mensagem original. Essas operações possuem aplicações legítimas, mas não substituem mecanismos de criptografia quando precisamos proteger informações contra leitura não autorizada.
Outro erro é trabalhar com chaves previsíveis ou armazená-las diretamente no código-fonte. Uma chave fixa incluída em um programa pode ser encontrada por quem possui acesso ao repositório ou ao executável, dependendo das condições. Também não devemos registrar segredos em logs nem transmiti-los por canais que não oferecem as propriedades de segurança necessárias.
Reutilizar um nonce em AES-GCM com a mesma chave representa outro problema grave. Embora o nonce não precise ser secreto, sua unicidade faz parte das condições de segurança do algoritmo. Por isso, devemos seguir as recomendações da API escolhida e preferir mecanismos que reduzam a possibilidade de reutilização acidental.
Também não devemos desativar a validação de certificados TLS apenas para eliminar mensagens de erro. Uma conexão criptografada sem autenticação adequada pode proteger os dados durante a transmissão para um participante que não representa o servidor esperado. Para evitar esse problema, precisamos validar a cadeia de confiança e o nome apresentado pelo certificado.
Protegendo chaves e certificados
A segurança de uma aplicação depende também da maneira como seus segredos são armazenados e administrados. Chaves privadas usadas em TLS ou assinaturas digitais precisam permanecer protegidas contra acesso não autorizado, enquanto certificados públicos normalmente podem ser distribuídos. Essa diferença deve ser considerada durante a criação de arquivos, configuração de permissões e implantação dos serviços.
Em aplicações maiores, podemos usar mecanismos próprios de gerenciamento de segredos, serviços de chaves ou recursos disponibilizados pelo ambiente de execução. Isso permite controlar o acesso, registrar determinadas operações e facilitar a substituição de material criptográfico. Entretanto, a solução adequada depende do tipo de aplicação e das exigências de segurança do sistema.
Também precisamos considerar a rotação de chaves e a validade dos certificados. Uma chave não deve ser considerada adequada para uso indefinido apenas porque foi gerada por um mecanismo seguro. Dependendo da finalidade, podemos precisar estabelecer períodos de validade, procedimentos de substituição e mecanismos para revogar credenciais comprometidas.
Escolhendo mecanismos adequados
Quando precisamos verificar a integridade de um arquivo usando um digest publicado por uma fonte confiável, SHA-256 pode ser adequado. Quando queremos autenticar mensagens usando uma chave compartilhada, podemos trabalhar com HMAC, que combina uma função de hash com uma chave secreta. Já para proteger informações que precisam ser recuperadas, podemos usar criptografia autenticada, como AES-GCM.
Quando precisamos assinar documentos ou mensagens usando pares de chaves, algoritmos como RSA-PSS, ECDSA ou Ed25519 podem atender a diferentes requisitos. Para proteger conexões de rede, normalmente devemos preferir protocolos conhecidos, como TLS, em vez de criar uma negociação criptográfica própria. Cada escolha precisa considerar compatibilidade, requisitos de segurança e características da aplicação.
Também não devemos assumir que usar o algoritmo mais complexo produz automaticamente a solução mais segura. Configurações inadequadas, ausência de validação, armazenamento incorreto de segredos ou erros no protocolo de aplicação podem comprometer mecanismos criptográficos conhecidos. Por isso, a segurança depende da combinação entre algoritmos apropriados, implementações confiáveis e procedimentos corretos de operação.