too much for one commit
This commit is contained in:
169
tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs
Normal file
169
tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
143
tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs
Normal file
143
tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
196
tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs
Normal file
196
tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs
Normal file
232
tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using EntKube.Secrets.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Secrets.Tests.Domain;
|
||||
|
||||
public class SecretScopeTests
|
||||
{
|
||||
// ─── Factory Method Tests ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCluster_CreatesInfrastructureScope()
|
||||
{
|
||||
// Arrange — Infrastructure secrets are scoped to Tenant + Environment + Cluster.
|
||||
// These are things like kubeconfig credentials, DNS tokens, cloud provider secrets.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
|
||||
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
|
||||
|
||||
// Assert
|
||||
|
||||
scope.TenantId.Should().Be(tenantId);
|
||||
scope.EnvironmentId.Should().Be(environmentId);
|
||||
scope.ClusterId.Should().Be(clusterId);
|
||||
scope.CustomerId.Should().BeNull();
|
||||
scope.AppName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForApp_CreatesApplicationScope()
|
||||
{
|
||||
// Arrange — Application secrets are scoped to Tenant + Customer + App + Environment.
|
||||
// These are things like database connection strings, API keys for third-party services.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
|
||||
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "my-api", environmentId);
|
||||
|
||||
// Assert
|
||||
|
||||
scope.TenantId.Should().Be(tenantId);
|
||||
scope.EnvironmentId.Should().Be(environmentId);
|
||||
scope.ClusterId.Should().BeNull();
|
||||
scope.CustomerId.Should().Be(customerId);
|
||||
scope.AppName.Should().Be("my-api");
|
||||
}
|
||||
|
||||
// ─── Record Construction Tests ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithAllFields_StoresAllValues()
|
||||
{
|
||||
// Arrange — A fully-scoped secret with all fields populated.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
|
||||
SecretScope scope = new(tenantId, environmentId, clusterId, customerId, "my-api");
|
||||
|
||||
// Assert
|
||||
|
||||
scope.TenantId.Should().Be(tenantId);
|
||||
scope.EnvironmentId.Should().Be(environmentId);
|
||||
scope.ClusterId.Should().Be(clusterId);
|
||||
scope.CustomerId.Should().Be(customerId);
|
||||
scope.AppName.Should().Be("my-api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithTenantOnly_AllowsNullOptionalFields()
|
||||
{
|
||||
// Arrange & Act — A tenant-wide secret (e.g. a shared API key)
|
||||
// that isn't scoped to a specific environment, cluster, customer, or app.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
SecretScope scope = new(tenantId, null, null, null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
scope.TenantId.Should().Be(tenantId);
|
||||
scope.EnvironmentId.Should().BeNull();
|
||||
scope.ClusterId.Should().BeNull();
|
||||
scope.CustomerId.Should().BeNull();
|
||||
scope.AppName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithTenantAndEnvironment_ScopesToEnvironment()
|
||||
{
|
||||
// Arrange & Act — A secret scoped to a tenant + environment
|
||||
// (e.g. a dev database password shared across all customers in dev).
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
SecretScope scope = new(tenantId, environmentId, null, null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
scope.TenantId.Should().Be(tenantId);
|
||||
scope.EnvironmentId.Should().Be(environmentId);
|
||||
scope.ClusterId.Should().BeNull();
|
||||
scope.CustomerId.Should().BeNull();
|
||||
scope.AppName.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── BuildPath Tests ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_TenantOnly_ReturnsPathWithTenantPrefix()
|
||||
{
|
||||
// Arrange — A tenant-scoped secret should have a path like
|
||||
// "tenants/{tenantId}/secrets/my-key".
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
SecretScope scope = new(tenantId, null, null, null, null);
|
||||
|
||||
// Act
|
||||
|
||||
string path = scope.BuildPath("my-key");
|
||||
|
||||
// Assert
|
||||
|
||||
path.Should().Be($"tenants/{tenantId}/secrets/my-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_TenantAndEnvironment_ReturnsEnvironmentScopedPath()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
SecretScope scope = new(tenantId, environmentId, null, null, null);
|
||||
|
||||
// Act
|
||||
|
||||
string path = scope.BuildPath("db-password");
|
||||
|
||||
// Assert
|
||||
|
||||
path.Should().Be($"tenants/{tenantId}/environments/{environmentId}/secrets/db-password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_ClusterScope_ReturnsClusterScopedPath()
|
||||
{
|
||||
// Arrange — Infrastructure secret scoped to a specific cluster.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
|
||||
|
||||
// Act
|
||||
|
||||
string path = scope.BuildPath("dns-credential");
|
||||
|
||||
// Assert — Path includes the cluster segment.
|
||||
|
||||
path.Should().Be(
|
||||
$"tenants/{tenantId}/environments/{environmentId}/clusters/{clusterId}/secrets/dns-credential");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_AppScope_ReturnsFullAppScopedPath()
|
||||
{
|
||||
// Arrange — Application secret: tenant → environment → customer → app.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "extract01", environmentId);
|
||||
|
||||
// Act
|
||||
|
||||
string path = scope.BuildPath("api-token");
|
||||
|
||||
// Assert
|
||||
|
||||
path.Should().Be(
|
||||
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/apps/extract01/secrets/api-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_CustomerWithoutApp_IncludesCustomerScope()
|
||||
{
|
||||
// Arrange — Customer-scoped without a specific app.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
SecretScope scope = new(tenantId, environmentId, null, customerId, null);
|
||||
|
||||
// Act
|
||||
|
||||
string path = scope.BuildPath("shared-token");
|
||||
|
||||
// Assert
|
||||
|
||||
path.Should().Be(
|
||||
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/secrets/shared-token");
|
||||
}
|
||||
|
||||
// ─── Equality ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Equality_SameValues_AreEqual()
|
||||
{
|
||||
// Arrange — Records should have value-based equality.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid envId = Guid.NewGuid();
|
||||
SecretScope scope1 = new(tenantId, envId, null, null, null);
|
||||
SecretScope scope2 = new(tenantId, envId, null, null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
scope1.Should().Be(scope2);
|
||||
}
|
||||
}
|
||||
178
tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs
Normal file
178
tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using EntKube.Secrets.Crypto;
|
||||
using EntKube.Secrets.Domain;
|
||||
using EntKube.Secrets.Infrastructure;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Secrets.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for service token authentication. Other EntKube services authenticate
|
||||
/// to the secrets service using service tokens scoped to specific path prefixes
|
||||
/// with specific permissions. Tokens are now tenant-scoped.
|
||||
/// </summary>
|
||||
public class ServiceTokenTests
|
||||
{
|
||||
private readonly InMemoryVaultRepository repository = new();
|
||||
private readonly byte[] kek = AesGcmEncryptor.GenerateKey();
|
||||
private readonly Guid tenantId = Guid.NewGuid();
|
||||
|
||||
private Vault CreateVault() => new(repository);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateToken_ReturnsTokenWithCorrectPolicies()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "clusters-service",
|
||||
policies: new List<AccessPolicy>
|
||||
{
|
||||
new("clusters/", AccessOperation.Read | AccessOperation.Write | AccessOperation.List),
|
||||
new("shared/certificates/", AccessOperation.Read)
|
||||
});
|
||||
|
||||
token.Token.Should().NotBeNullOrEmpty();
|
||||
token.Name.Should().Be("clusters-service");
|
||||
token.Policies.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithValidToken_ReturnsTrue()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "test-service",
|
||||
policies: new List<AccessPolicy>
|
||||
{
|
||||
new("test/", AccessOperation.Read)
|
||||
});
|
||||
|
||||
TokenValidationResult result = await vault.ValidateTokenAsync(token.Token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Name.Should().Be("test-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithInvalidToken_ReturnsFalse()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
TokenValidationResult result = await vault.ValidateTokenAsync("invalid-token-that-was-never-issued");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAccess_WithMatchingPolicy_AllowsOperation()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "clusters-service",
|
||||
policies: new List<AccessPolicy>
|
||||
{
|
||||
new("clusters/", AccessOperation.Read | AccessOperation.Write)
|
||||
});
|
||||
|
||||
bool canRead = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Read);
|
||||
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Write);
|
||||
|
||||
canRead.Should().BeTrue();
|
||||
canWrite.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAccess_WithNoMatchingPolicy_DeniesOperation()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "readonly-service",
|
||||
policies: new List<AccessPolicy>
|
||||
{
|
||||
new("clusters/", AccessOperation.Read)
|
||||
});
|
||||
|
||||
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/secret", AccessOperation.Write);
|
||||
bool canReadOther = await vault.CheckAccessAsync(token.Token, "identity/users/", AccessOperation.Read);
|
||||
|
||||
canWrite.Should().BeFalse();
|
||||
canReadOther.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeToken_PreventsSubsequentValidation()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "temporary-service",
|
||||
policies: new List<AccessPolicy>
|
||||
{
|
||||
new("temp/", AccessOperation.Read)
|
||||
});
|
||||
|
||||
TokenValidationResult validBefore = await vault.ValidateTokenAsync(token.Token);
|
||||
validBefore.IsValid.Should().BeTrue();
|
||||
|
||||
await vault.RevokeTokenAsync(token.Token);
|
||||
|
||||
TokenValidationResult validAfter = await vault.ValidateTokenAsync(token.Token);
|
||||
validAfter.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTokens_ReturnsActiveTokenMetadata()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.CreateServiceTokenAsync(tenantId, "service-a", new List<AccessPolicy>
|
||||
{
|
||||
new("a/", AccessOperation.Read)
|
||||
});
|
||||
|
||||
await vault.CreateServiceTokenAsync(tenantId, "service-b", new List<AccessPolicy>
|
||||
{
|
||||
new("b/", AccessOperation.Read | AccessOperation.Write)
|
||||
});
|
||||
|
||||
List<ServiceTokenInfo> tokens = await vault.ListTokensAsync(tenantId);
|
||||
|
||||
tokens.Should().HaveCount(2);
|
||||
tokens.Should().Contain(t => t.Name == "service-a");
|
||||
tokens.Should().Contain(t => t.Name == "service-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiInstance_TokenCreatedOnOnePodValidatesOnAnother()
|
||||
{
|
||||
Vault pod1 = CreateVault();
|
||||
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
Vault pod2 = CreateVault();
|
||||
await pod2.AutoUnsealAsync(kek);
|
||||
|
||||
ServiceTokenResult token = await pod1.CreateServiceTokenAsync(
|
||||
tenantId,
|
||||
name: "cross-pod-service",
|
||||
policies: new List<AccessPolicy> { new("shared/", AccessOperation.Read) });
|
||||
|
||||
TokenValidationResult result = await pod2.ValidateTokenAsync(token.Token);
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Name.Should().Be("cross-pod-service");
|
||||
}
|
||||
}
|
||||
518
tests/EntKube.Secrets.Tests/Domain/VaultTests.cs
Normal file
518
tests/EntKube.Secrets.Tests/Domain/VaultTests.cs
Normal file
@@ -0,0 +1,518 @@
|
||||
using EntKube.Secrets.Crypto;
|
||||
using EntKube.Secrets.Domain;
|
||||
using EntKube.Secrets.Infrastructure;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Secrets.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the multi-tenant Vault aggregate. Each tenant gets their own
|
||||
/// master encryption key (MEK) for cryptographic isolation.
|
||||
///
|
||||
/// The vault manages per-tenant MEKs — initialization, auto-unseal, and
|
||||
/// secret operations are all scoped to a specific tenant.
|
||||
/// </summary>
|
||||
public class VaultTests
|
||||
{
|
||||
private readonly InMemoryVaultRepository repository = new();
|
||||
private readonly byte[] kek = AesGcmEncryptor.GenerateKey();
|
||||
private readonly Guid tenantId = Guid.NewGuid();
|
||||
|
||||
private Vault CreateVault() => new(repository);
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_GeneratesMasterKeyAndAutoUnseals()
|
||||
{
|
||||
// Arrange — A fresh vault backed by an empty repository.
|
||||
|
||||
Vault vault = CreateVault();
|
||||
|
||||
// Act — Initialize a tenant with KEK, 5 Shamir shares, threshold of 3.
|
||||
|
||||
InitializationResult result = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
|
||||
|
||||
// Assert — Shares returned for DR, tenant's vault is immediately unsealed.
|
||||
|
||||
result.Shares.Should().HaveCount(5);
|
||||
result.Threshold.Should().Be(3);
|
||||
vault.IsTenantInitialized(tenantId).Should().BeTrue();
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_WhenAlreadyInitialized_Fails()
|
||||
{
|
||||
// A tenant's vault can only be initialized once.
|
||||
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
|
||||
Func<Task> act = () => vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*already initialized*");
|
||||
}
|
||||
|
||||
// ─── Auto-Unseal (Normal K8s Startup) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AutoUnseal_WithCorrectKek_UnsealsAllTenants()
|
||||
{
|
||||
// Arrange — Initialize two tenants, then seal both.
|
||||
|
||||
Guid tenant1 = Guid.NewGuid();
|
||||
Guid tenant2 = Guid.NewGuid();
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenant1, kek, totalShares: 1, threshold: 1);
|
||||
await vault.InitializeAsync(tenant2, kek, totalShares: 1, threshold: 1);
|
||||
await vault.SealAsync();
|
||||
|
||||
// Act — A new vault instance auto-unseals all tenants.
|
||||
|
||||
Vault vault2 = CreateVault();
|
||||
await vault2.AutoUnsealAsync(kek);
|
||||
|
||||
// Assert — Both tenants unsealed.
|
||||
|
||||
vault2.IsTenantSealed(tenant1).Should().BeFalse();
|
||||
vault2.IsTenantSealed(tenant2).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoUnseal_WithWrongKek_Fails()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.SealAsync();
|
||||
|
||||
byte[] wrongKek = AesGcmEncryptor.GenerateKey();
|
||||
Vault vault2 = CreateVault();
|
||||
|
||||
Func<Task> act = () => vault2.AutoUnsealAsync(wrongKek);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*KEK*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoUnseal_WhenNotInitialized_Fails()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
|
||||
Func<Task> act = () => vault.AutoUnsealAsync(kek);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*not initialized*");
|
||||
}
|
||||
|
||||
// ─── Manual Unseal (Disaster Recovery) ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ManualUnseal_WithEnoughShares_UnsealsVault()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
|
||||
await vault.SealAsync(tenantId);
|
||||
vault.IsTenantSealed(tenantId).Should().BeTrue();
|
||||
|
||||
// Act — Provide 3 of 5 shares.
|
||||
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[2]);
|
||||
UnsealResult unsealResult = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[4]);
|
||||
|
||||
unsealResult.IsUnsealed.Should().BeTrue();
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualUnseal_WithInsufficientShares_RemainsSealed()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
UnsealResult result1 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
|
||||
UnsealResult result2 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
|
||||
|
||||
result1.IsUnsealed.Should().BeFalse();
|
||||
result1.SharesProvided.Should().Be(1);
|
||||
result1.SharesRequired.Should().Be(3);
|
||||
|
||||
result2.IsUnsealed.Should().BeFalse();
|
||||
result2.SharesProvided.Should().Be(2);
|
||||
|
||||
vault.IsTenantSealed(tenantId).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ─── Multi-Instance (Simulating K8s Pods) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MultiInstance_BothPodsCanAutoUnseal()
|
||||
{
|
||||
Vault pod1 = CreateVault();
|
||||
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
Vault pod2 = CreateVault();
|
||||
await pod2.AutoUnsealAsync(kek);
|
||||
|
||||
pod1.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
pod2.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiInstance_SecretWrittenByOnePodReadableByAnother()
|
||||
{
|
||||
Vault pod1 = CreateVault();
|
||||
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
Vault pod2 = CreateVault();
|
||||
await pod2.AutoUnsealAsync(kek);
|
||||
|
||||
await pod1.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "cloudflare-api-token");
|
||||
|
||||
string? value = await pod2.GetSecretAsync(tenantId, "clusters/prod/dns-cred");
|
||||
value.Should().Be("cloudflare-api-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiInstance_SealingOnePodDoesNotAffectOther()
|
||||
{
|
||||
Vault pod1 = CreateVault();
|
||||
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await pod1.PutSecretAsync(tenantId, "shared/secret", "hello");
|
||||
|
||||
Vault pod2 = CreateVault();
|
||||
await pod2.AutoUnsealAsync(kek);
|
||||
|
||||
await pod1.SealAsync(tenantId);
|
||||
|
||||
pod1.IsTenantSealed(tenantId).Should().BeTrue();
|
||||
pod2.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
|
||||
string? value = await pod2.GetSecretAsync(tenantId, "shared/secret");
|
||||
value.Should().Be("hello");
|
||||
}
|
||||
|
||||
// ─── Multi-Tenant Isolation ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTenant_SecretsAreCryptographicallyIsolated()
|
||||
{
|
||||
// Each tenant has their own MEK. A secret written for tenant A
|
||||
// cannot be decrypted by tenant B's MEK.
|
||||
|
||||
Guid tenantA = Guid.NewGuid();
|
||||
Guid tenantB = Guid.NewGuid();
|
||||
Vault vault = CreateVault();
|
||||
|
||||
await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1);
|
||||
await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
// Each tenant writes a secret at the same logical key but different paths.
|
||||
|
||||
await vault.PutSecretAsync(tenantA, "tenants/a/config/api-key", "tenant-a-secret");
|
||||
await vault.PutSecretAsync(tenantB, "tenants/b/config/api-key", "tenant-b-secret");
|
||||
|
||||
// Each tenant can only read their own secrets.
|
||||
|
||||
string? valueA = await vault.GetSecretAsync(tenantA, "tenants/a/config/api-key");
|
||||
string? valueB = await vault.GetSecretAsync(tenantB, "tenants/b/config/api-key");
|
||||
|
||||
valueA.Should().Be("tenant-a-secret");
|
||||
valueB.Should().Be("tenant-b-secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTenant_SealingOneTenantDoesNotAffectAnother()
|
||||
{
|
||||
Guid tenantA = Guid.NewGuid();
|
||||
Guid tenantB = Guid.NewGuid();
|
||||
Vault vault = CreateVault();
|
||||
|
||||
await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1);
|
||||
await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
// Seal only tenant A.
|
||||
|
||||
await vault.SealAsync(tenantA);
|
||||
|
||||
vault.IsTenantSealed(tenantA).Should().BeTrue();
|
||||
vault.IsTenantSealed(tenantB).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ─── Seal / Unseal Lifecycle ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Seal_ClearsMasterKeyFromMemory()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
vault.IsTenantSealed(tenantId).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealThenAutoUnseal_Works()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
|
||||
await vault.SealAsync();
|
||||
vault.IsSealed.Should().BeTrue();
|
||||
|
||||
await vault.AutoUnsealAsync(kek);
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RepeatedSealUnsealCycles_Work()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
|
||||
await vault.SealAsync();
|
||||
await vault.AutoUnsealAsync(kek);
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
|
||||
await vault.SealAsync(tenantId);
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
|
||||
vault.IsTenantSealed(tenantId).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ─── KEK Rotation ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReWrapMasterKey_AllowsAutoUnsealWithNewKek()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.PutSecretAsync(tenantId, "test/secret", "original-value");
|
||||
|
||||
byte[] newKek = AesGcmEncryptor.GenerateKey();
|
||||
|
||||
await vault.ReWrapMasterKeyAsync(tenantId, newKek);
|
||||
|
||||
await vault.SealAsync();
|
||||
await vault.AutoUnsealAsync(newKek);
|
||||
|
||||
string? value = await vault.GetSecretAsync(tenantId, "test/secret");
|
||||
value.Should().Be("original-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReWrapMasterKey_OldKekNoLongerWorks()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
byte[] newKek = AesGcmEncryptor.GenerateKey();
|
||||
await vault.ReWrapMasterKeyAsync(tenantId, newKek);
|
||||
await vault.SealAsync();
|
||||
|
||||
Vault vault2 = CreateVault();
|
||||
|
||||
Func<Task> act = () => vault2.AutoUnsealAsync(kek);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// ─── Secret Operations ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PutSecret_WhenUnsealed_StoresEncryptedSecret()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential", "cloudflare-api-token-value");
|
||||
|
||||
string? retrieved = await vault.GetSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential");
|
||||
retrieved.Should().Be("cloudflare-api-token-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutSecret_WhenSealed_ThrowsVaultSealedException()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
Func<Task> act = () => vault.PutSecretAsync(tenantId, "path/to/secret", "value");
|
||||
await act.Should().ThrowAsync<VaultSealedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSecret_WhenSealed_ThrowsVaultSealedException()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.PutSecretAsync(tenantId, "my/secret", "value");
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
Func<Task> act = () => vault.GetSecretAsync(tenantId, "my/secret");
|
||||
await act.Should().ThrowAsync<VaultSealedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSecret_NonExistentPath_ReturnsNull()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
string? result = await vault.GetSecretAsync(tenantId, "does/not/exist");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutSecret_OverwritesExistingSecret()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "old-value");
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "new-value");
|
||||
|
||||
string? result = await vault.GetSecretAsync(tenantId, "config/api-key");
|
||||
|
||||
result.Should().Be("new-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutSecret_CreatesVersionHistory()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "v1");
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "v2");
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "v3");
|
||||
|
||||
string? current = await vault.GetSecretAsync(tenantId, "config/api-key");
|
||||
int versionCount = await vault.GetSecretVersionCountAsync(tenantId, "config/api-key");
|
||||
|
||||
current.Should().Be("v3");
|
||||
versionCount.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSecretVersion_RetrievesSpecificVersion()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "version-one");
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "version-two");
|
||||
await vault.PutSecretAsync(tenantId, "config/api-key", "version-three");
|
||||
|
||||
string? v1 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 1);
|
||||
string? v2 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 2);
|
||||
string? v3 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 3);
|
||||
|
||||
v1.Should().Be("version-one");
|
||||
v2.Should().Be("version-two");
|
||||
v3.Should().Be("version-three");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSecret_SoftDeletesAndPreventsRetrieval()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.PutSecretAsync(tenantId, "temp/secret", "temporary-value");
|
||||
|
||||
await vault.DeleteSecretAsync(tenantId, "temp/secret");
|
||||
|
||||
string? result = await vault.GetSecretAsync(tenantId, "temp/secret");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSecrets_ReturnsPathsWithoutValues()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "value1");
|
||||
await vault.PutSecretAsync(tenantId, "clusters/prod/tls-key", "value2");
|
||||
await vault.PutSecretAsync(tenantId, "clusters/staging/dns-cred", "value3");
|
||||
|
||||
List<string> paths = await vault.ListSecretsAsync(tenantId, "clusters/prod/");
|
||||
|
||||
paths.Should().HaveCount(2);
|
||||
paths.Should().Contain("clusters/prod/dns-cred");
|
||||
paths.Should().Contain("clusters/prod/tls-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSecrets_WhenSealed_ThrowsVaultSealedException()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
Func<Task> act = () => vault.ListSecretsAsync(tenantId, "any/");
|
||||
await act.Should().ThrowAsync<VaultSealedException>();
|
||||
}
|
||||
|
||||
// ─── Secrets Persist Across Seal/Unseal ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Secrets_SurviveSealAutoUnsealCycle()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "persistent/secret", "survive-seal-unseal");
|
||||
|
||||
await vault.SealAsync();
|
||||
|
||||
await vault.AutoUnsealAsync(kek);
|
||||
|
||||
string? recovered = await vault.GetSecretAsync(tenantId, "persistent/secret");
|
||||
recovered.Should().Be("survive-seal-unseal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secrets_SurviveManualShamirUnseal()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "dr/secret", "disaster-recovery-value");
|
||||
|
||||
await vault.SealAsync(tenantId);
|
||||
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
|
||||
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
|
||||
|
||||
string? recovered = await vault.GetSecretAsync(tenantId, "dr/secret");
|
||||
recovered.Should().Be("disaster-recovery-value");
|
||||
}
|
||||
|
||||
// ─── Audit ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Operations_AreAuditLogged()
|
||||
{
|
||||
Vault vault = CreateVault();
|
||||
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
|
||||
|
||||
await vault.PutSecretAsync(tenantId, "audit/test", "value");
|
||||
await vault.GetSecretAsync(tenantId, "audit/test");
|
||||
await vault.DeleteSecretAsync(tenantId, "audit/test");
|
||||
|
||||
List<AuditEntry> entries = await vault.GetAuditLogAsync();
|
||||
|
||||
entries.Should().Contain(e => e.Operation == "initialize");
|
||||
entries.Should().Contain(e => e.Operation == "put" && e.Path == "audit/test");
|
||||
entries.Should().Contain(e => e.Operation == "get" && e.Path == "audit/test");
|
||||
entries.Should().Contain(e => e.Operation == "delete" && e.Path == "audit/test");
|
||||
}
|
||||
}
|
||||
23
tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj
Normal file
23
tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\EntKube.Secrets\EntKube.Secrets.csproj" />
|
||||
<ProjectReference Include="..\..\src\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user