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.
176 lines
6.6 KiB
C#
176 lines
6.6 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.Commons.Interfaces.Transport;
|
|
using ScadaLink.Commons.Types.Transport;
|
|
|
|
namespace ScadaLink.Transport.Import;
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of <see cref="IBundleSessionStore"/> backed by a
|
|
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>. Sessions are evicted lazily
|
|
/// at read time (<see cref="Get"/>) and on-demand via <see cref="EvictExpired"/>;
|
|
/// there is no background timer.
|
|
/// <para>
|
|
/// Thread-safety: backed by <see cref="ConcurrentDictionary{TKey,TValue}"/> of
|
|
/// <see cref="Guid"/> to <see cref="BundleSession"/>. All store operations
|
|
/// (<see cref="Get"/> / <see cref="Open"/> / <see cref="Remove"/> /
|
|
/// <see cref="EvictExpired"/>) use the concurrent dictionary's safe primitives
|
|
/// (<c>TryGetValue</c>, indexer assignment, <c>TryRemove</c>) and are safe
|
|
/// under concurrent callers. The <see cref="BundleSession"/> instance itself
|
|
/// is NOT thread-safe — callers that share a session reference (e.g. two
|
|
/// importers mutating <c>FailedUnlockAttempts</c> on the same session) MUST
|
|
/// serialize their mutations on that shared reference.
|
|
/// </para>
|
|
/// <para>
|
|
/// TTL is supplied by the importer via <see cref="BundleSession.ExpiresAt"/>;
|
|
/// this store does not impose its own. The injected <see cref="TimeProvider"/>
|
|
/// is used purely to determine <c>now</c> when checking <c>ExpiresAt</c>, which
|
|
/// keeps unit tests deterministic.
|
|
/// </para>
|
|
/// <para>
|
|
/// The 3-strike unlock lockout is owned by <see cref="BundleSession"/>
|
|
/// (<c>FailedUnlockAttempts</c> / <c>Locked</c>); the store just hands out the
|
|
/// shared session reference so the importer can mutate the counter in place.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class BundleSessionStore : IBundleSessionStore
|
|
{
|
|
private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();
|
|
|
|
/// <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. <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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public BundleSession Open(BundleSession session)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(session);
|
|
// Overwrite on collision is defensive: GUIDs are random so practical
|
|
// collisions don't happen, but if a caller reuses an id we always
|
|
// honor the latest Open call.
|
|
_sessions[session.SessionId] = session;
|
|
return session;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public BundleSession? Get(Guid sessionId)
|
|
{
|
|
if (!_sessions.TryGetValue(sessionId, out var session)) return null;
|
|
if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session;
|
|
_sessions.TryRemove(sessionId, out _);
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Remove(Guid sessionId)
|
|
{
|
|
_sessions.TryRemove(sessionId, out _);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void EvictExpired()
|
|
{
|
|
var now = _timeProvider.GetUtcNow();
|
|
foreach (var kv in _sessions)
|
|
{
|
|
if (kv.Value.ExpiresAt <= now)
|
|
{
|
|
_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 _);
|
|
}
|
|
}
|