170 lines
5.5 KiB
C#
170 lines
5.5 KiB
C#
using System.Text;
|
|
using EntKube.Secrets.Crypto;
|
|
using FluentAssertions;
|
|
|
|
namespace EntKube.Secrets.Tests.Crypto;
|
|
|
|
/// <summary>
|
|
/// Tests for the AES-256-GCM encryption primitive. This is the foundation of
|
|
/// the entire secrets manager — every secret is encrypted with AES-256-GCM
|
|
/// before being stored. We verify that encryption produces ciphertext that
|
|
/// differs from plaintext, that decryption recovers the original data, and
|
|
/// that tampering with any part of the ciphertext is detected.
|
|
/// </summary>
|
|
public class AesGcmEncryptorTests
|
|
{
|
|
[Fact]
|
|
public void Encrypt_ThenDecrypt_RecoversOriginalPlaintext()
|
|
{
|
|
// Arrange — A 256-bit key and some plaintext to protect.
|
|
// This is the most basic contract: encrypt then decrypt = identity.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Encoding.UTF8.GetBytes("dns-solver-api-token-abc123");
|
|
|
|
// Act — Encrypt the plaintext, then decrypt the result.
|
|
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
|
|
|
|
// Assert — The recovered plaintext must match the original exactly.
|
|
|
|
recovered.Should().BeEquivalentTo(plaintext);
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_ProducesDifferentCiphertextEachTime()
|
|
{
|
|
// Arrange — Same key and same plaintext encrypted twice.
|
|
// AES-GCM uses a random nonce, so each encryption should produce
|
|
// different ciphertext even for identical inputs.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Encoding.UTF8.GetBytes("same-secret-value");
|
|
|
|
// Act
|
|
|
|
byte[] ciphertext1 = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
byte[] ciphertext2 = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
|
|
// Assert — Ciphertexts must differ (different nonces).
|
|
|
|
ciphertext1.Should().NotBeEquivalentTo(ciphertext2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_CiphertextDiffersFromPlaintext()
|
|
{
|
|
// Arrange
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Encoding.UTF8.GetBytes("my-super-secret-value");
|
|
|
|
// Act
|
|
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
|
|
// Assert — The ciphertext must not contain the plaintext verbatim.
|
|
|
|
string ciphertextStr = Encoding.UTF8.GetString(ciphertext);
|
|
ciphertextStr.Should().NotContain("my-super-secret-value");
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_WithWrongKey_ThrowsCryptographicException()
|
|
{
|
|
// Arrange — Encrypt with one key, try to decrypt with a different key.
|
|
// AES-GCM's authentication tag ensures this fails loudly.
|
|
|
|
byte[] correctKey = AesGcmEncryptor.GenerateKey();
|
|
byte[] wrongKey = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Encoding.UTF8.GetBytes("secret-data");
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(correctKey, plaintext);
|
|
|
|
// Act & Assert — Decryption with the wrong key must fail.
|
|
|
|
Action act = () => AesGcmEncryptor.Decrypt(wrongKey, ciphertext);
|
|
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_WithTamperedCiphertext_ThrowsCryptographicException()
|
|
{
|
|
// Arrange — Encrypt normally, then flip a bit in the ciphertext.
|
|
// AES-GCM's authenticated encryption detects any tampering.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Encoding.UTF8.GetBytes("integrity-protected-data");
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
|
|
// Tamper with a byte in the encrypted portion (after the nonce).
|
|
|
|
ciphertext[15] ^= 0xFF;
|
|
|
|
// Act & Assert — Tampered data must be rejected.
|
|
|
|
Action act = () => AesGcmEncryptor.Decrypt(key, ciphertext);
|
|
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateKey_Returns32Bytes()
|
|
{
|
|
// AES-256 requires a 256-bit (32-byte) key.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
|
|
key.Should().HaveCount(32);
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateKey_ProducesUniqueKeys()
|
|
{
|
|
// Two generated keys must never be the same (CSPRNG guarantee).
|
|
|
|
byte[] key1 = AesGcmEncryptor.GenerateKey();
|
|
byte[] key2 = AesGcmEncryptor.GenerateKey();
|
|
|
|
key1.Should().NotBeEquivalentTo(key2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_WithEmptyPlaintext_WorksCorrectly()
|
|
{
|
|
// Edge case — encrypting an empty byte array should still work.
|
|
// Some secrets might be empty during initialization.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = Array.Empty<byte>();
|
|
|
|
// Act
|
|
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
|
|
|
|
// Assert
|
|
|
|
recovered.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_WithLargePayload_WorksCorrectly()
|
|
{
|
|
// Secrets can be large (e.g., PEM certificates, JSON blobs).
|
|
// Verify AES-GCM handles payloads beyond typical small secrets.
|
|
|
|
byte[] key = AesGcmEncryptor.GenerateKey();
|
|
byte[] plaintext = new byte[64 * 1024]; // 64KB
|
|
Random.Shared.NextBytes(plaintext);
|
|
|
|
// Act
|
|
|
|
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
|
|
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
|
|
|
|
// Assert
|
|
|
|
recovered.Should().BeEquivalentTo(plaintext);
|
|
}
|
|
}
|