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:
Joseph Doherty
2026-05-28 04:14:07 -04:00
parent 291274ae76
commit 5d2386cc9d
20 changed files with 879 additions and 66 deletions
@@ -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