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
@@ -36,19 +36,35 @@ namespace ScadaLink.Transport.Import;
public sealed class BundleSessionStore : IBundleSessionStore
{
private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();
private readonly TimeProvider _timeProvider;
// Options are accepted to honor the documented constructor contract and to
// be ready for future per-store knobs (e.g. max in-flight sessions). The
// current store does not read any field — TTL is on the session itself.
/// <summary>
/// T-003: per-bundle unlock-failure counters, keyed by <see cref="BundleManifest.ContentHash"/>
/// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
/// <see cref="BundleSession"/> — so retries against the same bundle bytes from a
/// second tab / CLI caller share the counter and cannot side-step the lockout. Entries
/// expire on the same TTL as a session.
/// </summary>
private readonly ConcurrentDictionary<string, UnlockFailureRecord> _unlockFailures = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IOptions<TransportOptions> _options;
/// <summary>T-003: per-bundle unlock-failure entry with expiry.</summary>
private sealed class UnlockFailureRecord
{
public int Count;
public DateTimeOffset ExpiresAt;
}
/// <summary>
/// Initializes a new <see cref="BundleSessionStore"/>.
/// </summary>
/// <param name="options">Transport options (reserved for future per-store configuration).</param>
/// <param name="options">Transport options. <see cref="TransportOptions.BundleSessionTtlMinutes"/> is also used as the TTL for the T-003 per-bundle unlock-failure tracker.</param>
/// <param name="timeProvider">Time provider used to evaluate session expiry.</param>
public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_ = options.Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
@@ -90,5 +106,70 @@ public sealed class BundleSessionStore : IBundleSessionStore
_sessions.TryRemove(kv.Key, out _);
}
}
// T-003: also expire stale per-bundle unlock-failure entries so a bundle
// that was previously locked clears once the lockout window passes.
foreach (var kv in _unlockFailures)
{
if (kv.Value.ExpiresAt <= now)
{
_unlockFailures.TryRemove(kv.Key, out _);
}
}
}
/// <inheritdoc />
public int GetUnlockFailureCount(string bundleContentHash)
{
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
if (!_unlockFailures.TryGetValue(bundleContentHash, out var record))
{
return 0;
}
// Lazy expiry — if the entry has aged past its window treat it as cleared.
if (record.ExpiresAt <= _timeProvider.GetUtcNow())
{
_unlockFailures.TryRemove(bundleContentHash, out _);
return 0;
}
return record.Count;
}
/// <inheritdoc />
public int IncrementUnlockFailureCount(string bundleContentHash)
{
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
var now = _timeProvider.GetUtcNow();
var record = _unlockFailures.AddOrUpdate(
bundleContentHash,
_ => new UnlockFailureRecord { Count = 1, ExpiresAt = now + ttl },
(_, existing) =>
{
// Treat an expired record as a fresh start so a legitimate operator
// returning hours later does not face a stale lockout.
if (existing.ExpiresAt <= now)
{
existing.Count = 1;
}
else
{
existing.Count++;
}
existing.ExpiresAt = now + ttl;
return existing;
});
return record.Count;
}
/// <inheritdoc />
public void ClearUnlockFailures(string bundleContentHash)
{
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
_unlockFailures.TryRemove(bundleContentHash, out _);
}
}