Criptografia AES em C#
Você já quis criptografar alguns dados confidenciais? Então você provavelmente já se deparou com vários artigos sobre AES (Advanced Encryption Standard). A partir de agosto de 2019, o AES ainda é o algoritmo recomendado para usar, então vamos ver como você pode usá-lo.
Introdução e um pouco de teoria A primeira regra da criptografia do Clube da Luta não é tentar inventar a sua própria (a menos que você tenha as habilidades matemáticas, é claro), mas usar algum padrão testado em batalha como a AES.
Em muitos casos, exemplos de como usar AES estão incompletos ou mesmo reduzindo severamente o nível de segurança que o algoritmo fornece. Depois de passar algum tempo pesquisando o tema, passando por docs e RFCs, acho que tenho uma melhor compreensão de como ele deve ser usado e o que deve ser evitado. Então, neste artigo vou começar com um (mau) exemplo básico e passar por uma série de etapas enquanto gradualmente melhorá-lo. A versão final do código está na parte inferior do artigo se você apenas quiser agarrá-lo.
Às vezes, AES e Rijndael são usados intercambiavelmente. Para uma descrição mais extensa do que é a diferença entre eles eu recomendo ler através do artigo da Wikipédia sobre AES, mas para resumi-lo - Rijndael é o algoritmo subjacente e AES está apenas prescrevendo quais parâmetros devem ser usados. Estes são
- 128 bits para o bloco e
- 128, 192 ou 256 bits para a chave.
Errado, ruim, só não…
Eu estava hesitante em colocá-lo aqui, mas temos que começar em algum lugar. Abaixo está um exemplo de como NÃO fazê-lo.
public static class SymmetricEncryptor
{
// don't use this
static string password = "very strong password 123412;,[p;[; 172634812";
public static byte[] EncryptString(string toEncrypt)
{
var key = GetKey(password);
using (var aes = Aes.Create())
using (var encryptor = aes.CreateEncryptor(key, key))
{
var plainText = Encoding.UTF8.GetBytes(toEncrypt);
return encryptor.TransformFinalBlock(plainText, 0, plainText.Length);
}
}
public static string DecryptToString(byte[] encryptedData)
{
var key = GetKey(password);
using (var aes = Aes.Create())
using (var encryptor = aes.CreateDecryptor(key, key))
{
var decryptedBytes = encryptor
.TransformFinalBlock(encryptedData, 0, encryptedData.Length);
return Encoding.UTF8.GetString(decryptedBytes);
}
}
// converts password to 128 bit hash
private static byte[] GetKey(string password)
{
var keyBytes = Encoding.UTF8.GetBytes(password);
using (var md5 = MD5.Create())
{
return md5.ComputeHash(keyBytes);
}
}
}
… e como você poderia então usá-lo:
class Program
{
static void Main(string[] args)
{
var textToEncrypt = "something you want to hide";
Console.WriteLine("original text: {0}{1}{0}",
Environment.NewLine, textToEncrypt);
var encryptedData = SymmetricEncryptor.EncryptString(textToEncrypt);
Console.WriteLine("encrypted data:{0}{1}{0}",
Environment.NewLine, Convert.ToBase64String(encryptedData));
var decryptedText = SymmetricEncryptor.DecryptToString(encryptedData);
Console.WriteLine("decrypted text:{0}{1}{0}",
Environment.NewLine, decryptedText);
}
}
que dá:
original text:
something you want to hide
encrypted data:
zXnS9f+LqO6myn2BxxniMUmfzzU82d74GA35CwpgNqw=
decrypted text:
something you want to hide
À primeira vista, isso não parece ruim, mas do ponto de vista da segurança há vários problemas. Então vamos consertar!
Senha codificada em força (Hard Coded)
Com ferramentas como Ildasm.exe ou dotPeek, é muito fácil descompilar os binários e ver o código … e a senha. Sem mencionar que também permanece em seu histórico de controle de origem. Então vamos precisar passá-lo como um parâmetro. Você terá que carregá-lo de alguma fonte externa - arquivo config, variável de ambiente, Azure Key Vault, etc.
public static class SymmetricEncryptor
{
public static byte[] EncryptString(string toEncrypt, string password)
{
// ...
}
public static string DecryptToString(byte[] encryptedData, string password)
{
// ...
}
// ...
}
Introduza um pouco de aleatoriedade
Um problema com a solução atual é que ele sempre retorna o mesmo resultado (mesma sequência de bytes) quando dada a sua senha juntamente com os mesmos dados para criptografar. Por causa disso, é mais fácil para o invasor adivinhar sua senha. Há um parâmetro para inicializar o algoritmo, intuitivamente chamado de Vetor de Inicialização (IV),que resolve esse problema. O IV deve ser do mesmo tamanho do tamanho do bloco.
public static class SymmetricEncryptor
{
private const int AesBlockByteSize = 128 / 8;
private static readonly RandomNumberGenerator Random = RandomNumberGenerator.Create();
public static byte[] EncryptString(string toEncrypt, string password)
{
var key = GetKey(password);
using (var aes = Aes.Create())
{
var iv = GenerateRandomBytes(AesBlockByteSize);
using (var encryptor = aes.CreateEncryptor(key, iv))
{
var plainText = Encoding.UTF8.GetBytes(toEncrypt);
var cipherText = encryptor
.TransformFinalBlock(plainText, 0, plainText.Length);
var result = new byte[iv.Length + cipherText.Length];
iv.CopyTo(result, 0);
cipherText.CopyTo(result, iv.Length);
return result;
}
}
}
public static string DecryptToString(byte[] encryptedData, string password)
{
var key = GetKey(password);
using (var aes = Aes.Create())
{
var iv = encryptedData.Take(AesBlockByteSize).ToArray();
var cipherText = encryptedData.Skip(AesBlockByteSize).ToArray();
using (var encryptor = aes.CreateDecryptor(key, iv))
{
var decryptedBytes = encryptor
.TransformFinalBlock(cipherText, 0, cipherText.Length);
return Encoding.UTF8.GetString(decryptedBytes);
}
}
}
private static byte[] GetKey(string password)
{
var keyBytes = Encoding.UTF8.GetBytes(password);
using (var md5 = MD5.Create())
{
return md5.ComputeHash(keyBytes);
}
}
private static byte[] GenerateRandomBytes(int numberOfBytes)
{
var randomBytes = new byte[numberOfBytes];
Random.GetBytes(randomBytes);
return randomBytes;
}
}
Quando você executa o programa agora, ele ainda funciona, mas a sequência produzida de bytes representando seus dados criptografados é diferente cada vez que você executa o programa. Essa mudança introduz um novo problema, porém - como lidar com a nova IV gerada aleatoriamente, mais especificamente onde armazená-la? Você provavelmente notou que eu estou simplesmente pressiá-lo para os dados criptografados. Isso pode parecer estranho ou até assustador, mas uma intravenosa não é considerada como algo secreto, então não é um problema do ponto de vista da segurança.
Melhor manuseio de chaves
Para garantir que nossa senha seja utilizável como uma chave para a AES, estamos atualmente simplesmente hashing-lo com MD5. Isso se estende a uma senha mais curta ou mais longa em uma chave exatamente do tamanho que precisamos que seja. Mesmo que a entropia da nossa senha escolhida não aumente, ainda podemos fortalecer nossa chave resultante contra ataques de força bruta e dicionário. Algoritmos para este fim pertencem a uma categoria chamada funções de derivação de chaves baseadas em senha. Exemplos deles são PBKDF2, scrypt, Argon2 e outros. Embora o Argon2 seja a nova alternativa moderna ao PBKDF2, seu uso pode ser proibitivo em alguns aplicativos por razões de desempenho e sua disponibilidade em todas as plataformas também não é tão universal.
A partir de 2019, o PBKDF2 ainda pode oferecer boa resistência contra ataques se configurado bem. Quando você abre a especificação do PBKDF2 descrito no RFC 2898,você pode ver as opções e argumentos para a função:
- senha
- salt
- contagem de iteração,
- comprimento de saída,
- (opcionalmente) função pseudorandom (PRF)
Então vamos ver como você pode usá-lo:
public static class SymmetricEncryptor
{
private const int AesBlockByteSize = 128 / 8;
private const int PasswordSaltByteSize = 128 / 8;
private const int PasswordByteSize = 256 / 8;
private const int PasswordIterationCount = 100_000;
private static readonly Encoding StringEncoding = Encoding.UTF8;
private static readonly RandomNumberGenerator Random = RandomNumberGenerator.Create();
public static byte[] EncryptString(string toEncrypt, string password)
{
using (var aes = Aes.Create())
{
var keySalt = GenerateRandomBytes(PasswordSaltByteSize);
var key = GetKey(password, keySalt);
var iv = GenerateRandomBytes(AesBlockByteSize);
using (var encryptor = aes.CreateEncryptor(key, iv))
{
var plainText = StringEncoding.GetBytes(toEncrypt);
var cipherText = encryptor
.TransformFinalBlock(plainText, 0, plainText.Length);
var result = MergeArrays(keySalt, iv, cipherText);
return result;
}
}
}
public static string DecryptToString(byte[] encryptedData, string password)
{
using (var aes = Aes.Create())
{
var keySalt = encryptedData.Take(PasswordSaltByteSize).ToArray();
var key = GetKey(password, keySalt);
var iv = encryptedData
.Skip(PasswordSaltByteSize).Take(AesBlockByteSize).ToArray();
var cipherText = encryptedData
.Skip(PasswordSaltByteSize + AesBlockByteSize).ToArray();
using (var encryptor = aes.CreateDecryptor(key, iv))
{
var decryptedBytes = encryptor
.TransformFinalBlock(cipherText, 0, cipherText.Length);
return StringEncoding.GetString(decryptedBytes);
}
}
}
private static byte[] GetKey(string password, byte[] passwordSalt)
{
var keyBytes = StringEncoding.GetBytes(password);
using (var derivator = new Rfc2898DeriveBytes(
keyBytes, passwordSalt,
PasswordIterationCount, HashAlgorithmName.SHA256))
{
return derivator.GetBytes(PasswordByteSize);
}
}
private static byte[] GenerateRandomBytes(int numberOfBytes)
{
var randomBytes = new byte[numberOfBytes];
Random.GetBytes(randomBytes);
return randomBytes;
}
private static byte[] MergeArrays(params byte[][] arrays)
{
var merged = new byte[arrays.Sum(a => a.Length)];
var mergeIndex = 0;
for (int i = 0; i < arrays.GetLength(0); i++)
{
arrays[i].CopyTo(merged, mergeIndex);
mergeIndex += arrays[i].Length;
}
return merged;
}
}
Como pode ver, agora estou usando a classe no método GetKey. As keyBytes e passwordSalt são autoexpulativas, mas os próximos dois parâmetros merecem alguns comentários.Rfc2898DeriveBytes
PasswordIterationCount diz quantas vezes você quer executar a função pseudorandom; maior número -> tempo de computação mais longo - > mais difícil de adivinhar. Se o seu aplicativo estiver sendo executado em uma CPU relativamente moderna, sugiro começar com 100k e diminuí-la apenas se o tempo de computação for inaceitável. 1k é o padrão, mas isso é muito pouco para o hardware de hoje, especialmente quando se leva em consideração ferramentas como hashcat rodando em máquinas com múltiplas GPUs.
Escolhi o SHA256 como uma função pseudorandom (PRF) porque estou derivando 256 bits para a chave. Eu poderia usar o SHA-1 padrão, mas esse produz apenas 160 bits, o que tecnicamente não é um problema para PBKDF2, mas é menos ideal. Quando você pede PBKDF2 para mais bits do que a PRF pode produzir, PBKDF2 fará outra rodada do número especificado de iterações e outra rodada e outra … até que a soma seja maior ou igual ao número de bits necessário. Como as rondas podem ser executadas em paralelo, a melhoria de segurança não é muito maior quando se deriva chaves mais longas com PRFs com saídas menores, portanto é melhor escolher PRF com saída mais longa.
Assinatura
Um recurso de segurança adicional que precisamos adicionar é assinar. No momento, não temos como verificar se alguns dados estão faltando ou alguns dados extras foram injetados. Com a assinatura, garantiremos que nossa mensagem não tenha sido alterada de forma alguma.
Para implementá-lo, usaremos o código de autenticação de mensagens (MAC),mais especificamente o HMAC-SHA256, que pertence a uma subcategoria MAC chamada código de autenticação de mensagens baseada em hash.
De forma simplificada, a ideia por trás do HMAC é pegar sua mensagem criptografada, hash-la e, em seguida, hash-la novamente com uma chave de autenticação. É importante notar que esta chave deve ser diferente da usada para criptografar sua mensagem.
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
public static class SymmetricEncryptor
{
private const int AesBlockByteSize = 128 / 8;
private const int PasswordSaltByteSize = 128 / 8;
private const int PasswordByteSize = 256 / 8;
private const int PasswordIterationCount = 100_000;
private const int SignatureByteSize = 256 / 8;
private const int MinimumEncryptedMessageByteSize =
PasswordSaltByteSize + // auth salt
PasswordSaltByteSize + // key salt
AesBlockByteSize + // IV
AesBlockByteSize + // cipher text min length
SignatureByteSize; // signature tag
private static readonly Encoding StringEncoding = Encoding.UTF8;
private static readonly RandomNumberGenerator Random = RandomNumberGenerator.Create();
public static byte[] EncryptString(string toEncrypt, string password)
{
using (var aes = Aes.Create())
{
// encrypt
var keySalt = GenerateRandomBytes(PasswordSaltByteSize);
var key = GetKey(password, keySalt);
var iv = GenerateRandomBytes(AesBlockByteSize);
byte[] cipherText;
using (var encryptor = aes.CreateEncryptor(key, iv))
{
var plainText = StringEncoding.GetBytes(toEncrypt);
cipherText = encryptor
.TransformFinalBlock(plainText, 0, plainText.Length);
}
// sign
var authKeySalt = GenerateRandomBytes(PasswordSaltByteSize);
var authKey = GetKey(password, authKeySalt);
var result = MergeArrays(
additionalCapacity: SignatureByteSize,
authKeySalt, keySalt, iv, cipherText);
using (var hmac = new HMACSHA256(authKey))
{
var payloadToSignLength = result.Length - SignatureByteSize;
var signatureTag = hmac.ComputeHash(result, 0, payloadToSignLength);
signatureTag.CopyTo(result, payloadToSignLength);
}
return result;
}
}
public static string DecryptToString(byte[] encryptedData, string password)
{
if (encryptedData is null
|| encryptedData.Length < MinimumEncryptedMessageByteSize)
{
throw new ArgumentException("Invalid length of encrypted data");
}
var authKeySalt = encryptedData
.AsSpan(0, PasswordSaltByteSize).ToArray();
var keySalt = encryptedData
.AsSpan(PasswordSaltByteSize, PasswordSaltByteSize).ToArray();
var iv = encryptedData
.AsSpan(2 * PasswordSaltByteSize, AesBlockByteSize).ToArray();
var signatureTag = encryptedData
.AsSpan(encryptedData.Length - SignatureByteSize, SignatureByteSize).ToArray();
var cipherTextIndex = authKeySalt.Length + keySalt.Length + iv.Length;
var cipherTextLength =
encryptedData.Length - cipherTextIndex - signatureTag.Length;
var authKey = GetKey(password, authKeySalt);
var key = GetKey(password, keySalt);
// verify signature
using (var hmac = new HMACSHA256(authKey))
{
var payloadToSignLength = encryptedData.Length - SignatureByteSize;
var signatureTagExpected = hmac
.ComputeHash(encryptedData, 0, payloadToSignLength);
// constant time checking to prevent timing attacks
var signatureVerificationResult = 0;
for (int i = 0; i < signatureTag.Length; i++)
{
signatureVerificationResult |= signatureTag[i] ^ signatureTagExpected[i];
}
if (signatureVerificationResult != 0)
{
throw new CryptographicException("Invalid signature");
}
}
// decrypt
using (var aes = Aes.Create())
{
using (var encryptor = aes.CreateDecryptor(key, iv))
{
var decryptedBytes = encryptor
.TransformFinalBlock(encryptedData, cipherTextIndex, cipherTextLength);
return StringEncoding.GetString(decryptedBytes);
}
}
}
private static byte[] GetKey(string password, byte[] passwordSalt)
{
var keyBytes = StringEncoding.GetBytes(password);
using (var derivator = new Rfc2898DeriveBytes(
keyBytes, passwordSalt,
PasswordIterationCount, HashAlgorithmName.SHA256))
{
return derivator.GetBytes(PasswordByteSize);
}
}
private static byte[] GenerateRandomBytes(int numberOfBytes)
{
var randomBytes = new byte[numberOfBytes];
Random.GetBytes(randomBytes);
return randomBytes;
}
private static byte[] MergeArrays(int additionalCapacity = 0, params byte[][] arrays)
{
var merged = new byte[arrays.Sum(a => a.Length) + additionalCapacity];
var mergeIndex = 0;
for (int i = 0; i < arrays.GetLength(0); i++)
{
arrays[i].CopyTo(merged, mergeIndex);
mergeIndex += arrays[i].Length;
}
return merged;
}
}
Não há muito a dizer sobre a implementação - derivamos uma nova chave, criamos uma nova instância HMACSHA256, calculamos o hash e, finalmente, adicionamos ao resultado.
Uma coisinha que talvez valha a pena mencionar é a parte de descriptografia que lida com a verificação da assinatura. Estamos comparando todos os bytes da assinatura sem qualquer ramificação. Portanto, não importa quais dados obtenham, a verificação deve levar um tempo constante, assim, mitigando qualquer tentativa de ataque cronometragem.
Opções adicionais
Estamos quase no final, mas quero tocar brevemente algumas coisas adicionais. é a maneira recomendada de obter uma instância da melhor implementação disponível da classe abstrata e que também lhe dá bons padrões, mas eu ainda prefiro ser explícito, então eu adicionei uma função de ajudante pequena que eu posso então reutilizar.Aes.Create()Aes
private static Aes CreateAes()
{
var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
return aes;
}
Importante! Certifique-se sempre de que está usando o modo CBC sobre o BCE, já que o BCE tem sérios problemas de segurança.
Alternativas
Uma alternativa a essa abordagem de criptografar e, em seguida, assinar é usar algo como AES-GCM que pertence a uma categoria de algoritmos de criptografia autenticados e tem essa funcionalidade incluída. Infelizmente a implementação no CoreFX ainda não está lá, mas está chegando com .NET Core 3 (você pode dar uma olhada lá).
Se você está procurando por algo pronto agora, confira a conhecida biblioteca do BouncyCastle, que também fornece muito mais algoritmos criptográficos.
Código completo
Como prometido no início do post aqui é a versão final do código. Para facilitar a ver as mudanças, ou seja, tornar mais curtas, omiti intencionalmente o tratamento de erros e verificação de argumentos e mantive apenas as verificações mais importantes. Para torná-lo mais robusto e pronto para a produção, algumas validações adicionais terão que ser adicionadas.
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
public static class SymmetricEncryptor
{
private const int AesBlockByteSize = 128 / 8;
private const int PasswordSaltByteSize = 128 / 8;
private const int PasswordByteSize = 256 / 8;
private const int PasswordIterationCount = 100_000;
private const int SignatureByteSize = 256 / 8;
private const int MinimumEncryptedMessageByteSize =
PasswordSaltByteSize + // auth salt
PasswordSaltByteSize + // key salt
AesBlockByteSize + // IV
AesBlockByteSize + // cipher text min length
SignatureByteSize; // signature tag
private static readonly Encoding StringEncoding = Encoding.UTF8;
private static readonly RandomNumberGenerator Random = RandomNumberGenerator.Create();
public static byte[] EncryptString(string toEncrypt, string password)
{
// encrypt
var keySalt = GenerateRandomBytes(PasswordSaltByteSize);
var key = GetKey(password, keySalt);
var iv = GenerateRandomBytes(AesBlockByteSize);
byte[] cipherText;
using (var aes = CreateAes())
using (var encryptor = aes.CreateEncryptor(key, iv))
{
var plainText = StringEncoding.GetBytes(toEncrypt);
cipherText = encryptor
.TransformFinalBlock(plainText, 0, plainText.Length);
}
// sign
var authKeySalt = GenerateRandomBytes(PasswordSaltByteSize);
var authKey = GetKey(password, authKeySalt);
var result = MergeArrays(
additionalCapacity: SignatureByteSize,
authKeySalt, keySalt, iv, cipherText);
using (var hmac = new HMACSHA256(authKey))
{
var payloadToSignLength = result.Length - SignatureByteSize;
var signatureTag = hmac.ComputeHash(result, 0, payloadToSignLength);
signatureTag.CopyTo(result, payloadToSignLength);
}
return result;
}
public static string DecryptToString(byte[] encryptedData, string password)
{
if (encryptedData is null
|| encryptedData.Length < MinimumEncryptedMessageByteSize)
{
throw new ArgumentException("Invalid length of encrypted data");
}
var authKeySalt = encryptedData
.AsSpan(0, PasswordSaltByteSize).ToArray();
var keySalt = encryptedData
.AsSpan(PasswordSaltByteSize, PasswordSaltByteSize).ToArray();
var iv = encryptedData
.AsSpan(2 * PasswordSaltByteSize, AesBlockByteSize).ToArray();
var signatureTag = encryptedData
.AsSpan(encryptedData.Length - SignatureByteSize, SignatureByteSize).ToArray();
var cipherTextIndex = authKeySalt.Length + keySalt.Length + iv.Length;
var cipherTextLength =
encryptedData.Length - cipherTextIndex - signatureTag.Length;
var authKey = GetKey(password, authKeySalt);
var key = GetKey(password, keySalt);
// verify signature
using (var hmac = new HMACSHA256(authKey))
{
var payloadToSignLength = encryptedData.Length - SignatureByteSize;
var signatureTagExpected = hmac
.ComputeHash(encryptedData, 0, payloadToSignLength);
// constant time checking to prevent timing attacks
var signatureVerificationResult = 0;
for (int i = 0; i < signatureTag.Length; i++)
{
signatureVerificationResult |= signatureTag[i] ^ signatureTagExpected[i];
}
if (signatureVerificationResult != 0)
{
throw new CryptographicException("Invalid signature");
}
}
// decrypt
using (var aes = CreateAes())
{
using (var encryptor = aes.CreateDecryptor(key, iv))
{
var decryptedBytes = encryptor
.TransformFinalBlock(encryptedData, cipherTextIndex, cipherTextLength);
return StringEncoding.GetString(decryptedBytes);
}
}
}
private static Aes CreateAes()
{
var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
return aes;
}
private static byte[] GetKey(string password, byte[] passwordSalt)
{
var keyBytes = StringEncoding.GetBytes(password);
using (var derivator = new Rfc2898DeriveBytes(
keyBytes, passwordSalt,
PasswordIterationCount, HashAlgorithmName.SHA256))
{
return derivator.GetBytes(PasswordByteSize);
}
}
private static byte[] GenerateRandomBytes(int numberOfBytes)
{
var randomBytes = new byte[numberOfBytes];
Random.GetBytes(randomBytes);
return randomBytes;
}
private static byte[] MergeArrays(int additionalCapacity = 0, params byte[][] arrays)
{
var merged = new byte[arrays.Sum(a => a.Length) + additionalCapacity];
var mergeIndex = 0;
for (int i = 0; i < arrays.GetLength(0); i++)
{
arrays[i].CopyTo(merged, mergeIndex);
mergeIndex += arrays[i].Length;
}
return merged;
}
}
E lembre-se…
Mantenha-o em segredo, mantenha-o seguro!
Autor: Tom Rucki