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
@@ -243,9 +243,15 @@ public partial class TransportImport : ComponentBase
// ============================================================
/// <summary>
/// Submits the entered passphrase. On <see cref="CryptographicException"/>
/// increments the per-session counter; once the configured threshold is
/// reached the wizard resets to Step 1 with an explanatory error.
/// Submits the entered passphrase.
/// <para>
/// T-003: lockout enforcement is now server-side and keyed by the bundle's
/// content hash. <see cref="CryptographicException"/> means "wrong passphrase,
/// try again"; a <see cref="BundleLockedException"/> means the importer has
/// observed enough failures against this bundle to lock it (the count is
/// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
/// display ("3 of N attempts used") — it is no longer the source of truth.
/// </para>
/// </summary>
private async Task SubmitPassphraseAsync()
{
@@ -264,37 +270,28 @@ public partial class TransportImport : ComponentBase
await LoadPreviewAndAdvanceAsync();
}
}
catch (BundleLockedException ex)
{
// T-003: server-side lockout reached. Emit a final audit row so the
// lockout is visible in the audit log, reset the wizard, and surface
// the typed message verbatim.
_passphrase = string.Empty;
_failedUnlockAttempts = ex.FailedAttempts;
await EmitUnlockFailedAuditRowAsync(ex.BundleContentHash, ex.FailedAttempts, ex.Message);
_errorMessage = ex.Message;
ResetSessionState();
_step = ImportWizardStep.Upload;
}
catch (CryptographicException ex)
{
_failedUnlockAttempts++;
_passphrase = string.Empty;
// Emit audit row for every wrong-passphrase attempt (BundleImportUnlockFailed).
// Best-effort — audit failure must never abort the user-facing action.
try
{
var user = await Auth.GetCurrentUsernameAsync();
var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
action: "BundleImportUnlockFailed",
entityType: "Bundle",
entityId: entityId,
entityName: entityName,
afterState: new
{
AttemptNumber = _failedUnlockAttempts,
Reason = ex.Message,
},
cancellationToken: CancellationToken.None);
await DbContext.SaveChangesAsync();
}
catch
{
// Audit failure is non-fatal — swallow and continue.
}
var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
await EmitUnlockFailedAuditRowAsync(entityId, _failedUnlockAttempts, ex.Message);
// The server tracks the authoritative counter; the local count is
// kept in sync for the Razor display only.
if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
{
_errorMessage =
@@ -318,6 +315,37 @@ public partial class TransportImport : ComponentBase
}
}
/// <summary>
/// T-003: best-effort audit row for a wrong-passphrase attempt. Audit failure
/// must never abort the user-facing action — same defensive pattern as the
/// original page used.
/// </summary>
private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
action: "BundleImportUnlockFailed",
entityType: "Bundle",
entityId: entityId,
entityName: entityName,
afterState: new
{
AttemptNumber = attemptNumber,
Reason = reason,
},
cancellationToken: CancellationToken.None);
await DbContext.SaveChangesAsync();
}
catch
{
// Audit failure is non-fatal — swallow and continue.
}
}
private void BackToUpload()
{
_step = ImportWizardStep.Upload;
@@ -15,4 +15,29 @@ public interface IBundleSessionStore
void Remove(Guid sessionId);
/// <summary>Removes all sessions whose expiry has passed.</summary>
void EvictExpired();
/// <summary>
/// T-003: returns the current unlock-failure count for a bundle keyed by its
/// content hash. The counter is server-owned so a second tab / CLI caller
/// cannot side-step the lockout by re-uploading the same bytes.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
/// <returns>Number of recorded failures for this bundle (0 if none, or if any record has expired).</returns>
int GetUnlockFailureCount(string bundleContentHash);
/// <summary>
/// T-003: atomically increments the unlock-failure counter for a bundle and
/// returns the new count. Tracking is scoped by content hash so retries
/// against identical bundle bytes are throttled regardless of client.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
int IncrementUnlockFailureCount(string bundleContentHash);
/// <summary>
/// T-003: clears the unlock-failure counter for a bundle (called on a
/// successful unlock so a legitimate operator who eventually types the
/// right passphrase is not penalised for earlier typos).
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
void ClearUnlockFailures(string bundleContentHash);
}
@@ -10,8 +10,18 @@ public sealed class BundleSession
public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
/// <summary>UTC timestamp after which this session is considered expired and must be re-uploaded.</summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>Number of failed passphrase unlock attempts for this session.</summary>
/// <summary>
/// T-003: legacy per-session unlock-attempt counter. The unlock lockout is now
/// owned by <c>IBundleSessionStore</c> and keyed by <c>BundleManifest.ContentHash</c>
/// so retries from a second tab / CLI caller share the counter. A successful
/// <c>LoadAsync</c> never increments this — it stays 0 on every opened session,
/// and <see cref="Locked"/> is unreachable on a session returned by the store.
/// Retained as a compatibility shim for callers/tests that still set it directly.
/// </summary>
public int FailedUnlockAttempts { get; set; }
/// <summary>True when three or more unlock attempts have failed, locking further attempts.</summary>
/// <summary>
/// T-003 legacy: always <c>false</c> on a session returned by <c>LoadAsync</c>
/// because lockout enforcement moved server-side; see <see cref="FailedUnlockAttempts"/>.
/// </summary>
public bool Locked => FailedUnlockAttempts >= 3;
}
@@ -0,0 +1,56 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Transport.Encryption;
/// <summary>
/// T-005: computes the AES-GCM Associated Authenticated Data (AAD) for a bundle's
/// encrypted payload. AAD is the SHA-256 of the manifest after normalising the two
/// derivative fields (<c>ContentHash</c>, <c>Encryption</c>) to known sentinel
/// values — those depend on the ciphertext and the IV, so they cannot themselves
/// be authenticated, but every OTHER manifest field (<c>SourceEnvironment</c>,
/// <c>ExportedBy</c>, <c>ScadaLinkVersion</c>, <c>Summary</c>, <c>Contents</c>,
/// <c>CreatedAtUtc</c>, …) participates in the GCM tag.
/// <para>
/// Threading this byte array through <c>AesGcm.Encrypt</c> / <c>AesGcm.Decrypt</c>
/// makes the Step-4 "type the source environment name to confirm" gate
/// tamper-evident: a flipped <c>SourceEnvironment</c> on a stolen bundle yields
/// an <c>AuthenticationTagMismatchException</c> on decrypt instead of producing
/// a valid plaintext with a forged origin label.
/// </para>
/// </summary>
public static class BundleManifestAad
{
/// <summary>
/// JSON options matching <c>BundleSerializer.JsonOptions</c> so the AAD bytes
/// are stable across the encrypt + decrypt side.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// Computes the AAD bytes for the supplied manifest. The two derivative
/// fields are normalised to fixed sentinels so the AAD is independent of the
/// ciphertext / IV that will eventually populate them in the on-disk
/// manifest.
/// </summary>
/// <param name="manifest">The manifest whose non-derivative fields should be authenticated.</param>
/// <returns>SHA-256 digest of the canonicalised manifest bytes.</returns>
public static byte[] Compute(BundleManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var canonical = manifest with
{
ContentHash = string.Empty,
Encryption = null,
};
var canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions);
return SHA256.HashData(canonicalBytes);
}
}
@@ -22,11 +22,20 @@ public sealed class BundleSecretEncryptor
/// <param name="plaintext">The data to encrypt.</param>
/// <param name="passphrase">The passphrase used to derive the encryption key.</param>
/// <param name="iterations">PBKDF2 iteration count for key derivation.</param>
/// <param name="associatedData">
/// T-005: optional AES-GCM Associated Authenticated Data — typically
/// <see cref="BundleManifestAad.Compute"/>(manifest). Binds the manifest's
/// non-derivative fields (source environment, exporter, summary, …) to the
/// ciphertext so any tampering of those fields yields an authentication-tag
/// mismatch on decrypt. Pass <see cref="ReadOnlySpan{Byte}.Empty"/> for the
/// legacy no-AAD format; the decrypter must mirror the choice.
/// </param>
/// <returns>The ciphertext (with appended GCM tag) and the encryption metadata needed to decrypt.</returns>
public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt(
ReadOnlySpan<byte> plaintext,
string passphrase,
int iterations)
int iterations,
ReadOnlySpan<byte> associatedData = default)
{
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
@@ -35,7 +44,7 @@ public sealed class BundleSecretEncryptor
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagBytes];
using var aes = new AesGcm(key, TagBytes);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
// Format: ciphertext || tag.
var output = new byte[ciphertext.Length + TagBytes];
@@ -54,8 +63,18 @@ public sealed class BundleSecretEncryptor
/// <param name="payload">The ciphertext with appended GCM tag.</param>
/// <param name="metadata">Encryption metadata carrying the algorithm, KDF, salt, nonce, and iterations.</param>
/// <param name="passphrase">The passphrase used to derive the decryption key.</param>
/// <param name="associatedData">
/// T-005: AAD that was passed to <see cref="Encrypt"/>. Must match exactly —
/// any tampering of the manifest used to derive AAD yields an authentication-
/// tag mismatch (surfaces as <see cref="CryptographicException"/>, specifically
/// <c>AuthenticationTagMismatchException</c> on .NET 10).
/// </param>
/// <returns>The decrypted plaintext bytes.</returns>
public byte[] Decrypt(ReadOnlySpan<byte> payload, EncryptionMetadata metadata, string passphrase)
public byte[] Decrypt(
ReadOnlySpan<byte> payload,
EncryptionMetadata metadata,
string passphrase,
ReadOnlySpan<byte> associatedData = default)
{
ArgumentNullException.ThrowIfNull(metadata);
@@ -79,7 +98,7 @@ public sealed class BundleSecretEncryptor
var plaintext = new byte[ctLen];
using var aes = new AesGcm(key, TagBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
return plaintext;
}
@@ -1,3 +1,4 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -142,6 +143,18 @@ public sealed class BundleImporter : IBundleImporter
$"Bundle exceeds maximum allowed size of {_options.Value.MaxBundleSizeMb} MB.");
}
// T-006: zip-bomb / decompression-bomb defences. We enforce three caps
// BEFORE any entry is decompressed by ReadManifest / ReadContentBytes:
// 1) total entry count
// 2) per-entry decompressed length
// 3) per-entry compression ratio (Length / CompressedLength)
// Each cap is configurable via TransportOptions so an operator can tune
// for an environment with legitimately large or unusually compressible
// bundles. Using ZipArchiveEntry.Length / .CompressedLength avoids
// reading the entry payload at all.
ms.Position = 0;
ValidateArchiveEnvelope(ms);
BundleManifest manifest;
try
{
@@ -181,12 +194,16 @@ public sealed class BundleImporter : IBundleImporter
throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
}
// Decrypt when the manifest carries EncryptionMetadata. AES-GCM tag
// mismatch surfaces as a CryptographicException (or its
// AuthenticationTagMismatchException subclass on .NET 10+) — bubble it
// unchanged so the caller can detect wrong-passphrase via type check
// and increment the lockout counter on the (about-to-be-rejected)
// session reference. The session is not opened on the failure path.
// Decrypt when the manifest carries EncryptionMetadata.
//
// T-003: lockout enforcement is server-side and keyed by ContentHash so a
// second tab / CLI caller re-uploading the same bundle bytes cannot
// side-step the limit by skipping the Razor page. The counter is
// consulted BEFORE attempting decrypt (rejects further attempts on an
// already-locked bundle) and incremented on a CryptographicException
// from _encryptor.Decrypt; a successful decrypt clears the counter so a
// legitimate operator who eventually types the right passphrase is not
// penalised for earlier typos.
byte[] decryptedContent;
if (manifest.Encryption is not null)
{
@@ -195,7 +212,39 @@ public sealed class BundleImporter : IBundleImporter
throw new ArgumentException(
"Passphrase required for encrypted bundle.", nameof(passphrase));
}
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase);
var maxAttempts = _options.Value.MaxUnlockAttemptsPerSession;
var priorFailures = _sessionStore.GetUnlockFailureCount(manifest.ContentHash);
if (priorFailures >= maxAttempts)
{
throw new BundleLockedException(manifest.ContentHash, priorFailures);
}
// T-005: bind the manifest's non-derivative fields into AES-GCM AAD so
// a tampered SourceEnvironment / ExportedBy / etc. yields an
// authentication-tag mismatch (surfaced as CryptographicException) on
// decrypt — preventing a forged origin label from slipping past the
// Step-4 typo-resistant confirmation gate.
var aad = Encryption.BundleManifestAad.Compute(manifest);
try
{
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
}
catch (CryptographicException)
{
var newCount = _sessionStore.IncrementUnlockFailureCount(manifest.ContentHash);
if (newCount >= maxAttempts)
{
// Surface the lockout as the typed exception so the caller can
// distinguish "wrong passphrase, try again" from "no more attempts".
throw new BundleLockedException(manifest.ContentHash, newCount);
}
// Otherwise rebubble the CryptographicException so the UI's
// wrong-passphrase audit + retry path continues to work unchanged.
throw;
}
_sessionStore.ClearUnlockFailures(manifest.ContentHash);
}
else
{
@@ -213,6 +262,57 @@ public sealed class BundleImporter : IBundleImporter
return _sessionStore.Open(session);
}
/// <summary>
/// T-006: validates the zip envelope against the configured caps BEFORE any
/// entry payload is decompressed. Reads only the central-directory headers
/// (<see cref="ZipArchiveEntry.Length"/> / <see cref="ZipArchiveEntry.CompressedLength"/>)
/// so a hostile bundle can't OOM the central node through this method itself.
/// </summary>
/// <param name="bundleBytes">Buffered bundle bytes. Position is preserved.</param>
private void ValidateArchiveEnvelope(MemoryStream bundleBytes)
{
var opts = _options.Value;
var maxEntries = opts.MaxBundleEntryCount;
var maxEntryDecompressed = opts.MaxBundleEntryDecompressedMb * 1024L * 1024L;
var maxRatio = opts.MaxBundleEntryCompressionRatio;
var savedPosition = bundleBytes.Position;
try
{
bundleBytes.Position = 0;
using var archive = new ZipArchive(bundleBytes, ZipArchiveMode.Read, leaveOpen: true);
if (archive.Entries.Count > maxEntries)
{
throw new InvalidDataException(
$"Bundle contains {archive.Entries.Count} zip entries; the configured maximum is {maxEntries}.");
}
foreach (var entry in archive.Entries)
{
if (entry.Length > maxEntryDecompressed)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' declares a decompressed size of {entry.Length} bytes; "
+ $"the configured maximum is {maxEntryDecompressed} bytes "
+ $"({opts.MaxBundleEntryDecompressedMb} MB).");
}
// CompressedLength of 0 means store-only or empty — skip ratio check.
if (entry.CompressedLength > 0 && entry.Length / entry.CompressedLength > maxRatio)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' has compression ratio "
+ $"{entry.Length / entry.CompressedLength}x; the configured maximum is {maxRatio}x.");
}
}
}
finally
{
bundleBytes.Position = savedPosition;
}
}
/// <inheritdoc />
public async Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
{
@@ -611,6 +711,12 @@ public sealed class BundleImporter : IBundleImporter
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return new ImportResult(
@@ -692,6 +798,14 @@ public sealed class BundleImporter : IBundleImporter
// any failure here so the original exception below propagates
// unchanged rather than being masked by an audit-layer fault.
}
// T-007: a failed apply used to leave the BundleSession (with its
// decrypted secrets) in the in-memory store for the full 30-minute
// TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
// Drop the session here too so the secrets are released as soon as
// the failure surfaces, not when the next Get() happens to evict.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
throw;
}
finally
@@ -704,6 +818,20 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// T-007: zeros the session's <see cref="BundleSession.DecryptedContent"/>
/// buffer in place so any caller still holding a reference observes the
/// cleared bytes. Best-effort — a null/empty buffer is a no-op.
/// </summary>
private static void ZeroDecryptedContent(BundleSession session)
{
var buf = session.DecryptedContent;
if (buf is { Length: > 0 })
{
Array.Clear(buf, 0, buf.Length);
}
}
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
private sealed class ImportSummary
{
@@ -0,0 +1,31 @@
namespace ScadaLink.Transport.Import;
/// <summary>
/// T-003: thrown by <see cref="BundleImporter.LoadAsync"/> when an encrypted bundle has
/// exceeded the configured failed-unlock attempt limit
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>). The lockout is tracked
/// server-side keyed by <c>BundleManifest.ContentHash</c>, so a second tab / CLI caller
/// re-uploading the same bytes hits the same counter and cannot side-step the limit.
/// </summary>
public sealed class BundleLockedException : Exception
{
/// <summary>Number of recorded unlock failures for this bundle.</summary>
public int FailedAttempts { get; }
/// <summary>SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key.</summary>
public string BundleContentHash { get; }
/// <summary>
/// Initializes a new <see cref="BundleLockedException"/>.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
/// <param name="failedAttempts">Number of failures recorded against this bundle.</param>
public BundleLockedException(string bundleContentHash, int failedAttempts)
: base(
$"Bundle is locked after {failedAttempts} failed unlock attempts. "
+ "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.")
{
BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash));
FailedAttempts = failedAttempts;
}
}
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Transport;
namespace ScadaLink.Transport.Import;
/// <summary>
/// T-007: periodic background sweep that drives <see cref="IBundleSessionStore.EvictExpired"/>
/// so abandoned import sessions clear from memory on their own, without needing a
/// new <see cref="IBundleSessionStore.Get"/> to trigger lazy eviction. Each session
/// owns the decrypted bundle content (potentially up to ~100 MB of secrets — DB
/// connection strings, SMTP credentials, external-system auth configs), and the
/// design contract is "bundles are not retained server-side after ApplyAsync
/// commits". This service keeps abandoned / failed sessions from pinning that
/// plaintext for the full 30-minute TTL when no other traffic flows.
/// </summary>
internal sealed class BundleSessionEvictionService : BackgroundService
{
private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1);
private readonly IBundleSessionStore _sessionStore;
private readonly ILogger<BundleSessionEvictionService> _logger;
public BundleSessionEvictionService(
IBundleSessionStore sessionStore,
ILogger<BundleSessionEvictionService> logger)
{
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(SweepInterval, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
try
{
_sessionStore.EvictExpired();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Bundle session sweep failed; will retry on next interval.");
}
}
}
}
@@ -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 _);
}
}
@@ -8,6 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
@@ -82,7 +82,17 @@ public sealed class BundleSerializer
// count but rebuild ContentHash + Encryption fields against the bytes we
// actually write. The non-encryption manifest fields (source env, exported
// by, summary, contents, version) are preserved verbatim.
var (cipher, freshMeta) = encryptor.Encrypt(contentBytes, passphrase, manifest.Encryption.Iterations);
//
// T-005: bind the manifest's non-derivative fields into the AES-GCM AAD
// so a tampered SourceEnvironment / ExportedBy / etc. on a stolen bundle
// yields an authentication-tag mismatch on decrypt instead of a forged
// origin label slipping past the Step-4 confirmation gate. AAD is
// computed over a manifest normalised to empty ContentHash + null
// Encryption (those fields are derivative of the ciphertext / IV and
// cannot themselves be authenticated).
var aad = BundleManifestAad.Compute(manifest);
var (cipher, freshMeta) = encryptor.Encrypt(
contentBytes, passphrase, manifest.Encryption.Iterations, aad);
payload = cipher;
payloadEntryName = ContentEncEntryName;
finalManifest = manifest with
@@ -178,7 +188,10 @@ public sealed class BundleSerializer
{
throw new ArgumentException("Encrypted bundle requires both passphrase and encryptor.");
}
plaintext = encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase);
// T-005: mirror the encrypt side — AAD is derived from the manifest's
// non-derivative fields. Tampering yields an authentication-tag mismatch.
var aad = BundleManifestAad.Compute(manifest);
plaintext = encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
}
else
{
@@ -35,6 +35,9 @@ public static class ServiceCollectionExtensions
services.AddScoped<DependencyResolver>();
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
// T-007: periodic eviction sweep so abandoned sessions clear without
// needing a fresh Get() to trigger lazy eviction.
services.AddHostedService<BundleSessionEvictionService>();
// SemanticValidator is a stateless utility used by ApplyAsync; use
// TryAdd so a host that already calls AddTemplateEngine() (which
// registers the same type as Transient) wins. Either registration
@@ -6,6 +6,28 @@ public sealed class TransportOptions
public int BundleSessionTtlMinutes { get; set; } = 30;
/// <summary>Gets or sets the maximum allowed bundle size in megabytes.</summary>
public int MaxBundleSizeMb { get; set; } = 100;
/// <summary>
/// T-006: maximum allowed decompressed size of any single zip entry in megabytes.
/// A 100 MB DEFLATE-compressed bundle can decompress to gigabytes; this cap
/// stops a malicious bundle from OOM-ing the central node before its entries
/// are decompressed.
/// </summary>
public int MaxBundleEntryDecompressedMb { get; set; } = 200;
/// <summary>
/// T-006: maximum permitted number of entries inside a bundle zip. A well-formed
/// bundle has exactly two (<c>manifest.json</c> plus <c>content.json</c> or
/// <c>content.enc</c>); a small upper bound limits the surface a zip-bomb can
/// exploit without rejecting future schema additions out of hand.
/// </summary>
public int MaxBundleEntryCount { get; set; } = 4;
/// <summary>
/// T-006: maximum permitted compression ratio (uncompressed length / compressed
/// length) per zip entry. Defence-in-depth against decompression bombs whose
/// declared <see cref="System.IO.Compression.ZipArchiveEntry.Length"/> is
/// trustworthy on read; legitimate JSON compresses around 510x, so 50x has
/// generous headroom for unusually compressible bundles.
/// </summary>
public int MaxBundleEntryCompressionRatio { get; set; } = 50;
/// <summary>Gets or sets the maximum number of failed passphrase unlock attempts before a session is locked.</summary>
public int MaxUnlockAttemptsPerSession { get; set; } = 3;
/// <summary>Gets or sets the maximum number of unlock attempts allowed per IP address per hour.</summary>