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.
540 lines
20 KiB
C#
540 lines
20 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Components.Forms;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.CentralUI.Auth;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Interfaces.Transport;
|
|
using ScadaLink.Commons.Types.Transport;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.Transport;
|
|
using ScadaLink.Transport.Import;
|
|
|
|
namespace ScadaLink.CentralUI.Components.Pages.Design;
|
|
|
|
/// <summary>
|
|
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
|
|
///
|
|
/// Five-step state machine:
|
|
/// <list type="number">
|
|
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
|
|
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
|
|
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
|
|
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
|
|
/// and retry LoadAsync; 3-strike lockout per the configured
|
|
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
|
|
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
|
|
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
|
|
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
|
|
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
|
|
/// guard prevents accidental cross-cluster overwrites.</item>
|
|
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
|
|
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
|
|
/// the error list and allow returning to Step 3.</item>
|
|
/// </list>
|
|
///
|
|
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
|
|
/// globally and must not be available to Design-only or Deployment-only users.
|
|
///
|
|
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
|
|
/// peeks the manifest by attempting decryption, encrypted bundles require two
|
|
/// LoadAsync invocations. We cache the raw bytes in <c>_bundleBytes</c> after the
|
|
/// first read so the user does not need to re-select the file before entering the
|
|
/// passphrase. The bytes are cleared on Done / Back-to-Upload.
|
|
/// </summary>
|
|
public partial class TransportImport : ComponentBase
|
|
{
|
|
public enum ImportWizardStep
|
|
{
|
|
Upload = 1,
|
|
Passphrase = 2,
|
|
Diff = 3,
|
|
Confirm = 4,
|
|
Result = 5,
|
|
}
|
|
|
|
// ---- Injected services ----
|
|
[Inject] private IBundleImporter BundleImporter { get; set; } = default!;
|
|
[Inject] private NavigationManager Nav { get; set; } = default!;
|
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
|
[Inject] private IOptions<TransportOptions> Options { get; set; } = default!;
|
|
[Inject] private IAuditService AuditService { get; set; } = default!;
|
|
[Inject] private ScadaLinkDbContext DbContext { get; set; } = default!;
|
|
|
|
// ---- Wizard state ----
|
|
private ImportWizardStep _step = ImportWizardStep.Upload;
|
|
private string? _errorMessage;
|
|
|
|
// ---- Session + cached bytes ----
|
|
// Bundle bytes are cached so the same file can be re-attempted with a
|
|
// passphrase without forcing the user to re-pick it. Cleared in ResetAll.
|
|
private byte[]? _bundleBytes;
|
|
private BundleSession? _session;
|
|
private bool _uploadInProgress;
|
|
|
|
// ---- Step 2: passphrase ----
|
|
private string _passphrase = string.Empty;
|
|
private int _failedUnlockAttempts;
|
|
|
|
// ---- Step 3: preview + resolutions ----
|
|
private ImportPreview? _preview;
|
|
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
|
|
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
|
|
|
|
// ---- Step 4: confirm ----
|
|
private string _confirmEnvironmentText = string.Empty;
|
|
|
|
// ---- Step 5: apply result ----
|
|
private bool _applyInProgress;
|
|
private ImportResult? _result;
|
|
private IReadOnlyList<string>? _validationErrors;
|
|
|
|
// ============================================================
|
|
// Step 1 — Upload
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// Buffers the selected file, enforces the configured size cap, then calls
|
|
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
|
|
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
|
|
/// which we catch and use to advance to Step 2 — the session is opened on
|
|
/// the second LoadAsync call once the passphrase is provided.
|
|
/// </summary>
|
|
private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
|
|
{
|
|
_errorMessage = null;
|
|
_uploadInProgress = true;
|
|
_session = null;
|
|
_bundleBytes = null;
|
|
try
|
|
{
|
|
var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
|
|
if (e.File.Size > maxBytes)
|
|
{
|
|
_errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
|
|
return;
|
|
}
|
|
|
|
// OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
|
|
// it to the configured cap so the read doesn't throw before we get
|
|
// to the importer's own length check.
|
|
using var fileStream = e.File.OpenReadStream(maxBytes);
|
|
using var ms = new MemoryStream();
|
|
await fileStream.CopyToAsync(ms);
|
|
_bundleBytes = ms.ToArray();
|
|
|
|
await TryLoadAsync(passphrase: null);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// Encrypted bundle, no passphrase yet — expected. The wizard
|
|
// advances to the passphrase step when the user clicks Next.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to read bundle: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_uploadInProgress = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
|
|
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
|
|
/// with no passphrase) leaves the wizard's step caller to advance to the
|
|
/// passphrase step. Wrong-passphrase failures surface as
|
|
/// <see cref="CryptographicException"/> and are counted by the caller.
|
|
/// </summary>
|
|
private async Task TryLoadAsync(string? passphrase)
|
|
{
|
|
if (_bundleBytes is null)
|
|
{
|
|
_errorMessage = "No bundle bytes cached — please re-select the file.";
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
using var stream = new MemoryStream(_bundleBytes);
|
|
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
|
|
_errorMessage = null;
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
|
|
// We deliberately do NOT set _errorMessage here; the page surfaces
|
|
// an empty Step-2 prompt instead.
|
|
_session = null;
|
|
throw;
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
// Wrong passphrase — bubble so the caller can increment the counter.
|
|
_session = null;
|
|
throw;
|
|
}
|
|
catch (InvalidDataException ex)
|
|
{
|
|
_session = null;
|
|
_errorMessage = $"Bundle is invalid: {ex.Message}";
|
|
}
|
|
catch (NotSupportedException ex)
|
|
{
|
|
_session = null;
|
|
_errorMessage = $"Bundle format unsupported: {ex.Message}";
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
_session = null;
|
|
_errorMessage = ex.Message;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances from Step 1 to either the passphrase step (encrypted bundle) or
|
|
/// straight to the diff step (unencrypted bundle). For encrypted bundles
|
|
/// LoadAsync was already attempted with <c>null</c> and threw
|
|
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
|
|
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
|
|
/// jump directly to Step 3.
|
|
/// </summary>
|
|
private async Task GoFromUploadAsync()
|
|
{
|
|
if (_session is null)
|
|
{
|
|
// Peek the manifest to find out if it's encrypted. We re-call LoadAsync
|
|
// with null passphrase; for encrypted bundles this throws
|
|
// ArgumentException → advance to Step 2.
|
|
try
|
|
{
|
|
await TryLoadAsync(passphrase: null);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
_step = ImportWizardStep.Passphrase;
|
|
return;
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
_errorMessage = "Bundle could not be decrypted.";
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_session is null)
|
|
{
|
|
// Some other error already surfaced via _errorMessage.
|
|
return;
|
|
}
|
|
if (_session.Manifest.Encryption is not null)
|
|
{
|
|
_step = ImportWizardStep.Passphrase;
|
|
}
|
|
else
|
|
{
|
|
await LoadPreviewAndAdvanceAsync();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Step 2 — Passphrase
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// 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()
|
|
{
|
|
if (string.IsNullOrEmpty(_passphrase))
|
|
{
|
|
return;
|
|
}
|
|
_uploadInProgress = true;
|
|
try
|
|
{
|
|
await TryLoadAsync(_passphrase);
|
|
if (_session is not null)
|
|
{
|
|
_failedUnlockAttempts = 0;
|
|
_passphrase = string.Empty;
|
|
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;
|
|
|
|
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 =
|
|
$"Too many failed unlock attempts ({_failedUnlockAttempts}). "
|
|
+ "Please re-upload the bundle.";
|
|
ResetSessionState();
|
|
_step = ImportWizardStep.Upload;
|
|
}
|
|
else
|
|
{
|
|
_errorMessage = "Wrong passphrase. Please try again.";
|
|
}
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
_errorMessage = "Passphrase required.";
|
|
}
|
|
finally
|
|
{
|
|
_uploadInProgress = false;
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
_errorMessage = null;
|
|
}
|
|
|
|
// ============================================================
|
|
// Step 3 — Diff & resolve conflicts
|
|
// ============================================================
|
|
|
|
private async Task LoadPreviewAndAdvanceAsync()
|
|
{
|
|
if (_session is null) return;
|
|
try
|
|
{
|
|
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
|
|
_resolutions = BuildDefaultResolutions(_preview);
|
|
_step = ImportWizardStep.Diff;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to build import preview: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the default resolution per preview item:
|
|
/// <list type="bullet">
|
|
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
|
|
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
|
|
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
|
|
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
|
|
/// </list>
|
|
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
|
|
/// </summary>
|
|
/// <param name="preview">The import preview containing all conflict items to map.</param>
|
|
/// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
|
|
internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
|
|
ImportPreview preview)
|
|
{
|
|
var map = new Dictionary<(string, string), ImportResolution>();
|
|
foreach (var item in preview.Items)
|
|
{
|
|
var action = item.Kind switch
|
|
{
|
|
ConflictKind.Identical => ResolutionAction.Skip,
|
|
ConflictKind.New => ResolutionAction.Add,
|
|
ConflictKind.Modified => ResolutionAction.Overwrite,
|
|
ConflictKind.Blocker => ResolutionAction.Skip,
|
|
_ => ResolutionAction.Skip,
|
|
};
|
|
map[(item.EntityType, item.Name)] = new ImportResolution(
|
|
item.EntityType, item.Name, action, RenameTo: null);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
|
|
{
|
|
if (_resolutions is null) return;
|
|
var existing = _resolutions[key];
|
|
_resolutions[key] = existing with { Action = action };
|
|
}
|
|
|
|
private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
|
|
{
|
|
if (_resolutions is null) return;
|
|
var existing = _resolutions[key];
|
|
_resolutions[key] = existing with { RenameTo = renameTo };
|
|
}
|
|
|
|
private void BulkSet(ResolutionAction action)
|
|
{
|
|
if (_resolutions is null || _preview is null) return;
|
|
foreach (var item in _preview.Items)
|
|
{
|
|
if (item.Kind != ConflictKind.Modified) continue;
|
|
var key = (item.EntityType, item.Name);
|
|
_resolutions[key] = _resolutions[key] with { Action = action };
|
|
}
|
|
}
|
|
|
|
private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
|
|
{
|
|
if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
|
|
var adds = 0;
|
|
var overs = 0;
|
|
var skips = 0;
|
|
var renames = 0;
|
|
var blockers = 0;
|
|
foreach (var item in _preview.Items)
|
|
{
|
|
if (item.Kind == ConflictKind.Blocker)
|
|
{
|
|
blockers++;
|
|
continue;
|
|
}
|
|
var action = _resolutions[(item.EntityType, item.Name)].Action;
|
|
switch (action)
|
|
{
|
|
case ResolutionAction.Add: adds++; break;
|
|
case ResolutionAction.Overwrite: overs++; break;
|
|
case ResolutionAction.Skip: skips++; break;
|
|
case ResolutionAction.Rename: renames++; break;
|
|
}
|
|
}
|
|
return (adds, overs, skips, renames, blockers);
|
|
}
|
|
|
|
private void GoToConfirm()
|
|
{
|
|
if (_preview is null) return;
|
|
if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
|
|
{
|
|
_errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
|
|
return;
|
|
}
|
|
_confirmEnvironmentText = string.Empty;
|
|
_step = ImportWizardStep.Confirm;
|
|
}
|
|
|
|
private void BackToDiff()
|
|
{
|
|
_step = ImportWizardStep.Diff;
|
|
_errorMessage = null;
|
|
_validationErrors = null;
|
|
_result = null;
|
|
}
|
|
|
|
// ============================================================
|
|
// Step 4 + 5 — Confirm & Apply
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
|
|
/// resolutions and the authenticated user identity. Distinguishes
|
|
/// <see cref="SemanticValidationException"/> (recoverable — surface the
|
|
/// error list and let the operator return to Step 3) from generic
|
|
/// exceptions (display generic error + force re-upload).
|
|
/// </summary>
|
|
private async Task ApplyAsync()
|
|
{
|
|
if (_session is null || _resolutions is null) return;
|
|
if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;
|
|
|
|
_applyInProgress = true;
|
|
_errorMessage = null;
|
|
_validationErrors = null;
|
|
_result = null;
|
|
try
|
|
{
|
|
var user = await Auth.GetCurrentUsernameAsync();
|
|
_result = await BundleImporter.ApplyAsync(
|
|
_session.SessionId,
|
|
_resolutions.Values.ToList(),
|
|
user,
|
|
CancellationToken.None);
|
|
_step = ImportWizardStep.Result;
|
|
}
|
|
catch (SemanticValidationException ex)
|
|
{
|
|
_validationErrors = ex.Errors;
|
|
_step = ImportWizardStep.Result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
|
|
_step = ImportWizardStep.Result;
|
|
}
|
|
finally
|
|
{
|
|
_applyInProgress = false;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Reset helpers
|
|
// ============================================================
|
|
|
|
private void ResetSessionState()
|
|
{
|
|
_session = null;
|
|
_bundleBytes = null;
|
|
_preview = null;
|
|
_resolutions = null;
|
|
_passphrase = string.Empty;
|
|
_confirmEnvironmentText = string.Empty;
|
|
_result = null;
|
|
_validationErrors = null;
|
|
}
|
|
}
|