too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,169 @@
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);
}
}

View File

@@ -0,0 +1,143 @@
using System.Text;
using EntKube.Secrets.Crypto;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Crypto;
/// <summary>
/// Tests for the envelope encryption engine. Envelope encryption is the core
/// pattern: each secret gets its own Data Encryption Key (DEK), the DEK encrypts
/// the secret, and the Master Encryption Key (MEK) encrypts the DEK. This way:
/// - Rotating the MEK only requires re-encrypting DEKs (not all secrets)
/// - Compromising one DEK only exposes one secret
/// - The MEK never directly touches secret data
/// </summary>
public class EnvelopeEncryptionTests
{
[Fact]
public void Seal_ThenOpen_RecoversOriginalPlaintext()
{
// Arrange — A master key and some secret data to protect.
// "Seal" wraps the data in an envelope (generates DEK, encrypts data,
// encrypts DEK). "Open" reverses the process.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("azure-dns-client-secret-value");
// Act
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
byte[] recovered = EnvelopeEncryption.Open(masterKey, envelope);
// Assert
recovered.Should().BeEquivalentTo(plaintext);
}
[Fact]
public void Seal_ProducesDifferentEnvelopesForSameData()
{
// Arrange — Each seal operation generates a new random DEK, so
// two envelopes for the same data should be completely different.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("same-secret");
// Act
EncryptedEnvelope envelope1 = EnvelopeEncryption.Seal(masterKey, plaintext);
EncryptedEnvelope envelope2 = EnvelopeEncryption.Seal(masterKey, plaintext);
// Assert — Both the encrypted DEK and encrypted data should differ.
envelope1.EncryptedDek.Should().NotBeEquivalentTo(envelope2.EncryptedDek);
envelope1.EncryptedData.Should().NotBeEquivalentTo(envelope2.EncryptedData);
}
[Fact]
public void Open_WithWrongMasterKey_Fails()
{
// Arrange — Seal with one MEK, try to open with another.
// The wrong MEK can't decrypt the DEK, so decryption fails.
byte[] correctMek = AesGcmEncryptor.GenerateKey();
byte[] wrongMek = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("secret-data");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(correctMek, plaintext);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(wrongMek, envelope);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void Open_WithTamperedEncryptedDek_Fails()
{
// Arrange — Tamper with the encrypted DEK.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("integrity-check");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
// Tamper with the encrypted DEK.
byte[] tamperedDek = (byte[])envelope.EncryptedDek.Clone();
tamperedDek[10] ^= 0xFF;
EncryptedEnvelope tampered = new(tamperedDek, envelope.EncryptedData);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(masterKey, tampered);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void Open_WithTamperedEncryptedData_Fails()
{
// Arrange — Tamper with the encrypted data payload.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("tamper-detection");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
byte[] tamperedData = (byte[])envelope.EncryptedData.Clone();
tamperedData[15] ^= 0xFF;
EncryptedEnvelope tampered = new(envelope.EncryptedDek, tamperedData);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(masterKey, tampered);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void ReWrap_ChangesEncryptedDekButPreservesData()
{
// Arrange — Re-wrapping is used during master key rotation. The secret
// data stays the same but the DEK is re-encrypted with the new MEK.
byte[] oldMek = AesGcmEncryptor.GenerateKey();
byte[] newMek = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("rewrap-test-secret");
EncryptedEnvelope original = EnvelopeEncryption.Seal(oldMek, plaintext);
// Act — Re-wrap: decrypt the DEK with old MEK, re-encrypt with new MEK.
EncryptedEnvelope rewrapped = EnvelopeEncryption.ReWrap(oldMek, newMek, original);
// Assert — The rewrapped envelope opens with the new MEK.
byte[] recovered = EnvelopeEncryption.Open(newMek, rewrapped);
recovered.Should().BeEquivalentTo(plaintext);
// Assert — The old MEK no longer works.
Action act = () => EnvelopeEncryption.Open(oldMek, rewrapped);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
}

View File

@@ -0,0 +1,196 @@
using EntKube.Secrets.Crypto;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Crypto;
/// <summary>
/// Tests for Shamir's Secret Sharing scheme. This is the mechanism that protects
/// the master encryption key — the key is split into N shares, and any M of those
/// shares can reconstruct it. Fewer than M shares reveal nothing about the key.
///
/// This is critical for the vault's seal/unseal lifecycle: on initialization the
/// master key is split into shares distributed to key holders. To unseal the vault
/// after a restart, M key holders must each provide their share.
/// </summary>
public class ShamirSecretSharingTests
{
[Fact]
public void Split_ThenCombine_WithExactThreshold_RecoversSecret()
{
// Arrange — A 256-bit master key, split into 5 shares with a threshold of 3.
// Any 3 shares should be enough to recover the original key.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act — Split into 5 shares, then recombine using exactly 3.
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares.Take(3).ToList(), threshold: 3);
// Assert — The recovered secret must match the original exactly.
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WithMoreThanThreshold_RecoversSecret()
{
// Arrange — Using more shares than required should also work.
// This tests that extra shares don't corrupt the reconstruction.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act — Split into 5, recombine using all 5 (threshold is still 3).
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3);
// Assert
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WithDifferentShareSubsets_AllRecoverSecret()
{
// Arrange — Any combination of M-of-N shares should work, not just
// the first M. This tests that shares 2,3,5 work as well as 1,2,3.
byte[] secret = AesGcmEncryptor.GenerateKey();
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Act & Assert — Multiple subsets of 3 shares all recover the secret.
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[0], shares[1], shares[2] }, 3)
.Should().BeEquivalentTo(secret, "shares 1,2,3 should recover the secret");
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[0], shares[2], shares[4] }, 3)
.Should().BeEquivalentTo(secret, "shares 1,3,5 should recover the secret");
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[1], shares[3], shares[4] }, 3)
.Should().BeEquivalentTo(secret, "shares 2,4,5 should recover the secret");
}
[Fact]
public void Split_ProducesCorrectNumberOfShares()
{
// Arrange
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 7, threshold: 4);
// Assert — Must produce exactly the requested number of shares.
shares.Should().HaveCount(7);
}
[Fact]
public void Split_EachShareHasUniqueIndex()
{
// Arrange
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Assert — Share indices must be unique (used as x-coordinates in Lagrange interpolation).
List<int> indices = shares.Select(s => s.Index).ToList();
indices.Should().OnlyHaveUniqueItems();
}
[Fact]
public void Split_WithThresholdOf1_EachShareIsTheSecret()
{
// Arrange — Threshold of 1 means any single share recovers the secret.
// This is the degenerate case (no split protection).
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 1);
// Assert — Each individual share should recover the secret.
foreach (ShamirShare share in shares)
{
byte[] recovered = ShamirSecretSharing.Combine(new List<ShamirShare> { share }, threshold: 1);
recovered.Should().BeEquivalentTo(secret);
}
}
[Fact]
public void Split_WithThresholdEqualsTotal_RequiresAllShares()
{
// Arrange — 3-of-3 means all shares are needed.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3);
// Assert
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_WithInvalidParameters_Throws()
{
// Threshold must be >= 1, total must be >= threshold.
byte[] secret = AesGcmEncryptor.GenerateKey();
Action zeroThreshold = () => ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 0);
zeroThreshold.Should().Throw<ArgumentException>();
Action thresholdExceedsTotal = () => ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 5);
thresholdExceedsTotal.Should().Throw<ArgumentException>();
}
[Fact]
public void Combine_WithTooFewShares_ProducesWrongResult()
{
// Arrange — With fewer than threshold shares, Lagrange interpolation
// produces a different polynomial, so the result should not match.
// This is the information-theoretic security guarantee of Shamir's scheme.
byte[] secret = AesGcmEncryptor.GenerateKey();
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Act — Try to combine only 2 shares when 3 are needed.
byte[] wrongResult = ShamirSecretSharing.Combine(shares.Take(2).ToList(), threshold: 2);
// Assert — The result must NOT equal the original secret.
wrongResult.Should().NotBeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WorksWithDifferentSecretSizes()
{
// Shamir's scheme works byte-by-byte, so it should handle any size secret.
byte[] smallSecret = new byte[] { 0x42 };
byte[] largeSecret = new byte[64]; // 512-bit key
Random.Shared.NextBytes(largeSecret);
List<ShamirShare> smallShares = ShamirSecretSharing.Split(smallSecret, 3, 2);
List<ShamirShare> largeShares = ShamirSecretSharing.Split(largeSecret, 3, 2);
ShamirSecretSharing.Combine(smallShares.Take(2).ToList(), 2)
.Should().BeEquivalentTo(smallSecret);
ShamirSecretSharing.Combine(largeShares.Take(2).ToList(), 2)
.Should().BeEquivalentTo(largeSecret);
}
}