5d2386cc9d
T-003: move the unlock lockout server-side. The 3-strike counter used to live in the Razor page only — a second tab / CLI caller could re-upload the same bytes and grind PBKDF2 indefinitely. The counter now lives in IBundleSessionStore, keyed by ContentHash, so retries against identical bundle bytes are throttled regardless of client. BundleLockedException surfaces the new typed error path. T-005: bind the manifest's non-derivative fields into AES-GCM AAD. A SHA-256 of the manifest (with ContentHash + Encryption normalised to sentinels) is now passed to AesGcm.Encrypt / .Decrypt, so a tampered SourceEnvironment / ExportedBy / CreatedAtUtc on a stolen bundle yields an authentication-tag mismatch instead of slipping past the Step-4 typo-resistant confirmation gate. T-006: cap zip entry count, decompressed length, and compression ratio in LoadAsync's envelope validator BEFORE any payload is decompressed, using ZipArchiveEntry.Length / .CompressedLength. New TransportOptions fields default to 4 entries / 200 MB / 50x ratio. T-007: clear decrypted plaintext on the ApplyAsync failure path and zero the buffer on success before removing the session, so a 100 MB DecryptedContent doesn't sit in memory for the 30-min TTL after a failed apply. A BundleSessionEvictionService BackgroundService now also drives EvictExpired periodically so abandoned sessions clear without needing a fresh Get() call to trigger lazy eviction. Also resolves NO-010 — the misleading "writer never throws" XML doc was the same code+comment my prior NO-004 await-the-writer fix already rewrote.
110 lines
4.3 KiB
C#
110 lines
4.3 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using ScadaLink.Transport.Encryption;
|
|
|
|
namespace ScadaLink.Transport.Tests.Encryption;
|
|
|
|
public sealed class BundleSecretEncryptorTests
|
|
{
|
|
private const int TestIterations = 10_000; // Lower than production for test speed.
|
|
|
|
[Fact]
|
|
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
|
|
{
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "correct-horse-battery-staple", TestIterations);
|
|
var recovered = sut.Decrypt(ciphertext, metadata, "correct-horse-battery-staple");
|
|
|
|
Assert.Equal(plaintext, recovered);
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_with_wrong_passphrase_throws_CryptographicException()
|
|
{
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("secret payload");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "right-pass", TestIterations);
|
|
|
|
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "wrong-pass"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_with_tampered_ciphertext_throws_CryptographicException()
|
|
{
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("secret payload");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations);
|
|
ciphertext[0] ^= 0xFF; // Flip every bit in the first ciphertext byte.
|
|
|
|
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "pass"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv()
|
|
{
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("same input");
|
|
|
|
var (ct1, meta1) = sut.Encrypt(plaintext, "pass", TestIterations);
|
|
var (ct2, meta2) = sut.Encrypt(plaintext, "pass", TestIterations);
|
|
|
|
Assert.NotEqual(ct1, ct2);
|
|
Assert.NotEqual(meta1.IvB64, meta2.IvB64);
|
|
Assert.NotEqual(meta1.SaltB64, meta2.SaltB64);
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_with_mismatched_AAD_throws_CryptographicException()
|
|
{
|
|
// T-005: AES-GCM AAD binds extra context (e.g. manifest non-derivative fields)
|
|
// into the auth tag. Decrypting with different AAD yields a tag mismatch even
|
|
// when the passphrase and ciphertext are correct.
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("secret payload");
|
|
var aadOnEncrypt = Encoding.UTF8.GetBytes("manifest-canonical-hash-A");
|
|
var aadOnDecrypt = Encoding.UTF8.GetBytes("manifest-canonical-hash-B");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations, aadOnEncrypt);
|
|
|
|
Assert.ThrowsAny<CryptographicException>(
|
|
() => sut.Decrypt(ciphertext, metadata, "pass", aadOnDecrypt));
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_with_matching_AAD_recovers_plaintext()
|
|
{
|
|
// T-005: round-trip with non-empty AAD must succeed when both sides match.
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("secret payload");
|
|
var aad = Encoding.UTF8.GetBytes("manifest-canonical-hash");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations, aad);
|
|
var recovered = sut.Decrypt(ciphertext, metadata, "pass", aad);
|
|
|
|
Assert.Equal(plaintext, recovered);
|
|
}
|
|
|
|
[Fact]
|
|
public void Encrypt_emits_metadata_matching_decryption_inputs()
|
|
{
|
|
var sut = new BundleSecretEncryptor();
|
|
var plaintext = Encoding.UTF8.GetBytes("payload");
|
|
|
|
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations);
|
|
|
|
Assert.Equal("AES-256-GCM", metadata.Algorithm);
|
|
Assert.Equal("PBKDF2-SHA256", metadata.Kdf);
|
|
Assert.Equal(TestIterations, metadata.Iterations);
|
|
// Salt is 16 bytes (24 chars b64 incl padding), Iv is 12 bytes (16 chars b64 incl padding).
|
|
Assert.Equal(16, Convert.FromBase64String(metadata.SaltB64).Length);
|
|
Assert.Equal(12, Convert.FromBase64String(metadata.IvB64).Length);
|
|
|
|
var recovered = sut.Decrypt(ciphertext, metadata, "pass");
|
|
Assert.Equal(plaintext, recovered);
|
|
}
|
|
}
|