fix(transport): close bundle security + plaintext-retention gaps (4 findings)
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.
This commit is contained in:
@@ -310,6 +310,40 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync());
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
||||
}
|
||||
|
||||
// T-007: a failed apply used to keep the BundleSession (and its decrypted
|
||||
// secrets) in the in-memory store for the full 30-minute TTL. The session
|
||||
// must now be removed immediately so the plaintext is released.
|
||||
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
||||
Assert.Null(sessionStore.Get(sessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_removes_session_on_success_path_too()
|
||||
{
|
||||
// T-007: companion to the failed-apply test — the success path must also
|
||||
// remove the session (it was already doing so before T-007, but the new
|
||||
// test asserts the contract explicitly so a future refactor cannot
|
||||
// accidentally leave plaintext in the store).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
|
||||
user: "alice");
|
||||
}
|
||||
|
||||
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
||||
Assert.Null(sessionStore.Get(sessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -57,6 +57,37 @@ public sealed class BundleSecretEncryptorTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -207,6 +207,225 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(stream, passphrase: "wrong"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers()
|
||||
{
|
||||
// T-003: the lockout is server-side and keyed by ContentHash, so replaying
|
||||
// the SAME bundle bytes from a second caller (different stream, different
|
||||
// session) must hit the same counter. After MaxUnlockAttemptsPerSession (3)
|
||||
// failures the importer throws BundleLockedException, not another
|
||||
// CryptographicException — and the lock survives a fresh LoadAsync from a
|
||||
// pristine caller that has no idea about the prior attempts.
|
||||
var rig = BuildRig();
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
// First two wrong-passphrase attempts surface as CryptographicException.
|
||||
for (var attempt = 1; attempt <= 2; attempt++)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||
}
|
||||
|
||||
// Third wrong-passphrase attempt crosses the threshold and surfaces as
|
||||
// BundleLockedException — even though this is a fresh stream / a caller
|
||||
// that never saw the earlier failures.
|
||||
var locked = await Assert.ThrowsAsync<BundleLockedException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||
Assert.Equal(3, locked.FailedAttempts);
|
||||
|
||||
// Fourth attempt — even with the CORRECT passphrase — is still locked,
|
||||
// because the lockout sticks until the TTL expires or the bundle is
|
||||
// re-exported with a new content hash.
|
||||
await Assert.ThrowsAsync<BundleLockedException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_too_many_entries()
|
||||
{
|
||||
// T-006: a malicious bundle could pad the archive with arbitrary entries to
|
||||
// exhaust per-entry handles or to slip an unexpected payload past the
|
||||
// serializer. The envelope check rejects any archive whose entry count
|
||||
// exceeds MaxBundleEntryCount BEFORE any decompression.
|
||||
var rig = BuildRig(opts => opts.MaxBundleEntryCount = 2);
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
|
||||
var bytes = ((MemoryStream)packed).ToArray();
|
||||
var paddedBytes = AppendExtraZipEntry(bytes, "extra.bin", new byte[8]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_oversized_entry()
|
||||
{
|
||||
// T-006: caller-declared decompressed Length above the configured cap is
|
||||
// a hostile bundle; reject without decompressing.
|
||||
var rig = BuildRig(opts => opts.MaxBundleEntryDecompressedMb = 1);
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
|
||||
var bytes = ((MemoryStream)packed).ToArray();
|
||||
// Replace content.json with a 2 MB entry of compressible zeros — uncompressed Length > cap.
|
||||
var bigPayload = new byte[2 * 1024 * 1024];
|
||||
var oversizedBytes = ReplaceZipEntry(bytes, "content.json", bigPayload);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio()
|
||||
{
|
||||
// T-006: defence-in-depth — even if Length is within the per-entry cap, an
|
||||
// extreme compression ratio is a hallmark of a decompression bomb and is
|
||||
// rejected outright.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxBundleEntryDecompressedMb = 100;
|
||||
opts.MaxBundleEntryCompressionRatio = 10;
|
||||
});
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
|
||||
var bytes = ((MemoryStream)packed).ToArray();
|
||||
// 1 MB of zeros compresses extremely well (>100x ratio) — well over the
|
||||
// configured 10x cap.
|
||||
var compressible = new byte[1024 * 1024];
|
||||
var bombBytes = ReplaceZipEntry(bytes, "content.json", compressible);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bombBytes), passphrase: null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-006 helper: rewrites an existing zip to add a fresh entry alongside the
|
||||
/// originals. Used by the "too many entries" test.
|
||||
/// </summary>
|
||||
private static byte[] AppendExtraZipEntry(byte[] zipBytes, string newEntryName, byte[] payload)
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
using (var src = new MemoryStream(zipBytes))
|
||||
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
|
||||
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in srcZip.Entries)
|
||||
{
|
||||
var dst = dstZip.CreateEntry(entry.FullName);
|
||||
using var inStream = entry.Open();
|
||||
using var outStream = dst.Open();
|
||||
inStream.CopyTo(outStream);
|
||||
}
|
||||
var extra = dstZip.CreateEntry(newEntryName);
|
||||
using (var s = extra.Open()) { s.Write(payload); }
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-006 helper: rewrites an existing zip, replacing one entry's bytes with
|
||||
/// the supplied payload while preserving every other entry verbatim.
|
||||
/// </summary>
|
||||
private static byte[] ReplaceZipEntry(byte[] zipBytes, string entryToReplace, byte[] newPayload)
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
using (var src = new MemoryStream(zipBytes))
|
||||
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
|
||||
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in srcZip.Entries)
|
||||
{
|
||||
var dst = dstZip.CreateEntry(entry.FullName);
|
||||
using var outStream = dst.Open();
|
||||
if (entry.FullName == entryToReplace)
|
||||
{
|
||||
outStream.Write(newPayload);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var inStream = entry.Open();
|
||||
inStream.CopyTo(outStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase()
|
||||
{
|
||||
// T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment,
|
||||
// ExportedBy, …) have been edited must fail decryption with a tag mismatch.
|
||||
// Without AAD an attacker could rewrite the SourceEnvironment label and slip
|
||||
// past the Step-4 typo-resistant confirmation gate. We tamper the field by
|
||||
// re-packing the manifest (with everything else, including the original
|
||||
// ciphertext, unchanged) into a fresh ZIP and verify decrypt fails.
|
||||
var rig = BuildRig();
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
// Read manifest + ciphertext from the legit bundle, mutate SourceEnvironment,
|
||||
// and re-pack with the SAME ciphertext bytes — the cipher is fine, only the
|
||||
// plaintext manifest is changed.
|
||||
BundleManifest originalManifest;
|
||||
byte[] cipherBytes;
|
||||
using (var src = new MemoryStream(bytes))
|
||||
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
srcZip.GetEntry("manifest.json")!.Open().CopyTo(ms);
|
||||
originalManifest = JsonSerializer.Deserialize<BundleManifest>(ms.ToArray(), BundleJsonOptions)!;
|
||||
|
||||
using var ctMs = new MemoryStream();
|
||||
srcZip.GetEntry("content.enc")!.Open().CopyTo(ctMs);
|
||||
cipherBytes = ctMs.ToArray();
|
||||
}
|
||||
|
||||
var tampered = originalManifest with { SourceEnvironment = "prod-spoofed" };
|
||||
var tamperedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(tampered, BundleJsonOptions);
|
||||
|
||||
var tamperedZipBytes = new MemoryStream();
|
||||
using (var outZip = new ZipArchive(tamperedZipBytes, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var mEntry = outZip.CreateEntry("manifest.json");
|
||||
using (var s = mEntry.Open()) { s.Write(tamperedManifestBytes); }
|
||||
var cEntry = outZip.CreateEntry("content.enc");
|
||||
using (var s = cEntry.Open()) { s.Write(cipherBytes); }
|
||||
}
|
||||
tamperedZipBytes.Position = 0;
|
||||
|
||||
// ContentHash check is the FIRST thing that catches the tamper here because
|
||||
// ContentHash is sealed against the cipher bytes and the cipher is unchanged
|
||||
// BUT the manifest's plaintext fields are mutated — wait, ContentHash is
|
||||
// against the cipher which we kept, so it still matches. The defence is
|
||||
// the AES-GCM AAD: the tag check fails because AAD differs.
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase()
|
||||
{
|
||||
// T-003: a legitimate operator who typos once or twice before getting it
|
||||
// right must not be penalised on the next bundle. A successful LoadAsync
|
||||
// clears the per-bundle counter.
|
||||
var rig = BuildRig();
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||
|
||||
var session = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct");
|
||||
Assert.NotNull(session);
|
||||
|
||||
// Counter cleared — a subsequent wrong-passphrase attempt starts from 0
|
||||
// and surfaces as CryptographicException, not BundleLockedException.
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported()
|
||||
{
|
||||
|
||||
@@ -96,8 +96,13 @@ public sealed class BundleSessionStoreTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_failed_unlock_attempts_locks_session()
|
||||
public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference()
|
||||
{
|
||||
// T-003 legacy guard: the per-session FailedUnlockAttempts / Locked fields on
|
||||
// BundleSession are no longer the source of truth (lockout moved to the store
|
||||
// keyed by ContentHash), but the fields remain as a compatibility shim for
|
||||
// callers/tests that still set them directly. The store hands out the shared
|
||||
// reference so mutations made on one Get() are observable from another.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
var sut = new BundleSessionStore(Options(), clock);
|
||||
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
|
||||
@@ -120,6 +125,62 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.True(refetch!.Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockFailureCount_TracksPerBundleAndResets()
|
||||
{
|
||||
// T-003: the per-bundle unlock-failure counter is keyed by ContentHash and
|
||||
// shared across sessions, so a second tab / CLI caller cannot side-step the
|
||||
// lockout by re-uploading the same bytes. Increment must be atomic and
|
||||
// ClearUnlockFailures must drop the entry so a legitimate operator who
|
||||
// eventually types the right passphrase is not stuck on a stale count.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
var sut = new BundleSessionStore(Options(), clock);
|
||||
const string contentHashA = "sha-A";
|
||||
const string contentHashB = "sha-B";
|
||||
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
|
||||
Assert.Equal(1, sut.IncrementUnlockFailureCount(contentHashA));
|
||||
Assert.Equal(2, sut.IncrementUnlockFailureCount(contentHashA));
|
||||
|
||||
// Per-bundle isolation: another bundle's counter is unaffected.
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashB));
|
||||
|
||||
sut.ClearUnlockFailures(contentHashA);
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockFailures_ExpireOnTtlAndGetReturnsZero()
|
||||
{
|
||||
// T-003: failure records share the session TTL so a bundle that was locked
|
||||
// hours ago clears on its own. Get must lazily expire stale entries.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
var sut = new BundleSessionStore(Options(), clock);
|
||||
const string contentHash = "sha-expiring";
|
||||
|
||||
sut.IncrementUnlockFailureCount(contentHash);
|
||||
sut.IncrementUnlockFailureCount(contentHash);
|
||||
Assert.Equal(2, sut.GetUnlockFailureCount(contentHash));
|
||||
|
||||
// Advance beyond the configured TTL (default 30 min).
|
||||
clock.Advance(TimeSpan.FromMinutes(31));
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockFailures_EvictExpired_ClearsStaleEntries()
|
||||
{
|
||||
// T-003: EvictExpired sweep cleans the per-bundle counters too, not just sessions.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
var sut = new BundleSessionStore(Options(), clock);
|
||||
sut.IncrementUnlockFailureCount("sha-old");
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(31));
|
||||
sut.EvictExpired();
|
||||
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount("sha-old"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-test <see cref="TimeProvider"/> with a mutable clock. Used in
|
||||
/// place of <c>Microsoft.Extensions.TimeProvider.Testing</c> (not in the
|
||||
|
||||
Reference in New Issue
Block a user