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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user