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;