diff --git a/docs/components/Transport.md b/docs/components/Transport.md
index cddd079c..c005d609 100644
--- a/docs/components/Transport.md
+++ b/docs/components/Transport.md
@@ -118,7 +118,15 @@ Inbound API keys are explicitly excluded — per re-architecture C4, keys are en
**Phase 1 — `LoadAsync`**: copies the upload stream to a seekable `MemoryStream`, enforces the bundle size cap (`MaxBundleSizeMb`), validates the ZIP envelope (entry count, per-entry decompressed size, compression ratio — all before decompression), reads and validates `manifest.json` (format version, SHA-256 content hash), and decrypts `content.enc` when `Encryption` is present. The decrypted bytes are stored in a new `BundleSession`.
-Passphrase lockout operates at two levels: per-bundle (3-strike counter in `BundleSessionStore`, keyed by `ContentHash` so a second browser tab sharing the same bundle bytes cannot reset the counter) and per-IP-per-hour (`BundleUnlockRateLimiter`, default 10 attempts). A successful decrypt clears the per-bundle counter.
+Passphrase lockout operates at two levels: per-bundle (3-strike counter in `BundleSessionStore`, keyed by `ContentHash` so a second browser tab sharing the same bundle bytes cannot reset the counter) and per-IP-per-hour (`BundleUnlockRateLimiter`, `MaxUnlockAttemptsPerIpPerHour`, default 10 attempts). A successful decrypt clears the per-bundle counter.
+
+The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real):
+
+1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget.
+2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures.
+3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before.
+
+The client key is the caller's remote IP: the Central UI wizard reads it from `CircuitClientAddressProvider` (a scoped service that snapshots `Connection.RemoteIpAddress` at circuit establishment, since `HttpContext` is gone by the time a circuit submits a passphrase — the same pattern as `CookieAuthenticationStateProvider`), and the management endpoint stamps it on `ManagementEnvelope.ClientIp` for the CLI `transport preview` / `transport import` paths. A caller with no attributable address is counted against a single **shared** bucket rather than skipping the throttle — "unknown client" degrades to a stricter budget, never to none. As with the login throttle (`Component-Security.md`), per-client isolation is only real when ForwardedHeaders is configured for the fronting proxy; behind an untrusted proxy every caller collapses into the proxy's bucket.
```csharp
// From BundleImporter.LoadAsync — decrypt path (simplified)
@@ -249,6 +257,10 @@ A template's scripts reference a `SharedScript` or `ExternalSystem` that exists
After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt.
+### Unlock throttled ("Too many bundle unlock attempts")
+
+`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP and rolls forward on a sliding window, and the counters are process-local (a central node restart clears them, deliberately, so an outage doesn't strand legitimate operators). The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely.
+
### BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings
These audit rows appear when the second-pass rewire (`ResolveAlarmScriptLinksAsync` / `ResolveCompositionEdgesAsync`) cannot match a name to a persisted row. The import commits — the FK is left null / the composition row is skipped — but the warning signals an incomplete import. Re-examine the bundle's dependency graph and re-export with the missing artifacts included.
diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md
index 2b150c16..a1b7287e 100644
--- a/docs/requirements/Component-Transport.md
+++ b/docs/requirements/Component-Transport.md
@@ -112,7 +112,7 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
flowchart TD
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
EXPORTER["IBundleExporter ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
- IMPORTER["IBundleImporter LoadAsync(stream, Passphrase?, ct) → BundleSession PreviewAsync(sessionId, ct) → ImportPreview ApplyAsync(sessionId, resolutions, ct) → ImportResult Abandon(sessionId)"]
+ IMPORTER["IBundleImporter LoadAsync(stream, Passphrase?, ct, ClientIp?) → BundleSession PreviewAsync(sessionId, ct) → ImportPreview ApplyAsync(sessionId, resolutions, ct) → ImportResult Abandon(sessionId)"]
RESOLVER["DependencyResolver"]
SERIALIZER["BundleSerializer (manifest + content JSON; ZIP packer)"]
ENCRYPTOR["BundleSecretEncryptor (AES-256-GCM + PBKDF2)"]
@@ -336,7 +336,7 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
- **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
-- **Failed-unlock rate limit:** per-session 3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row.
+- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.)
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
- **In-transit:** existing HTTPS to the Central UI; no new channel.
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CircuitClientAddressProvider.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CircuitClientAddressProvider.cs
new file mode 100644
index 00000000..a591fb49
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CircuitClientAddressProvider.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Http;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
+
+///
+/// Supplies the remote IP of the client that established this Blazor circuit.
+///
+/// Same constraint — and the same solution — as
+/// :
+/// is valid only during the initial HTTP request that establishes the circuit and
+/// is null (or stale/foreign) for the life of the SignalR connection afterwards.
+/// Registered Scoped, this type is constructed inside that request's DI
+/// scope, so it snapshots Connection.RemoteIpAddressonce in the
+/// constructor and serves that snapshot for the circuit's lifetime.
+///
+///
+/// The value is only as trustworthy as the proxy configuration: it is whatever
+/// Kestrel reports as the connection's remote address, which ForwardedHeaders
+/// (see ForwardedHeadersSetup, enabled for the shipped Traefik topologies)
+/// rewrites from X-Forwarded-For for CONFIGURED proxies only. Behind an
+/// untrusted proxy every circuit collapses onto the proxy's address — consumers
+/// must treat that as a shared bucket, not as per-client isolation.
+///
+///
+public sealed class CircuitClientAddressProvider
+{
+ ///
+ /// Snapshots the client address from the current HTTP context for use throughout
+ /// the circuit lifetime.
+ ///
+ /// Accessor used to read the circuit-establishing request.
+ public CircuitClientAddressProvider(IHttpContextAccessor httpContextAccessor)
+ {
+ ArgumentNullException.ThrowIfNull(httpContextAccessor);
+ ClientAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
+ }
+
+ ///
+ /// Test seam: constructs the provider with an explicit address instead of reading
+ /// an .
+ ///
+ /// The address to serve, or null for "unknown".
+ public CircuitClientAddressProvider(string? clientAddress)
+ {
+ ClientAddress = clientAddress;
+ }
+
+ ///
+ /// The remote IP of the circuit-establishing request, or null when it could not be
+ /// determined (no HTTP context, or a transport that reports no remote address).
+ ///
+ public string? ClientAddress { get; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs
index d7a5a2f6..dd4b93dd 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs
@@ -86,6 +86,11 @@ public partial class TransportImport : ComponentBase, IDisposable
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
+ // Register row 44: the per-IP-per-hour unlock throttle needs the caller's
+ // address, and HttpContext is long gone by the time a circuit submits a
+ // passphrase — this provider snapshots it when the circuit was established.
+ [Inject] private CircuitClientAddressProvider ClientAddress { get; set; } = default!;
+
// The Map step needs the destination environment's sites + each
// site's connections to populate the "map to existing target" dropdowns.
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
@@ -236,7 +241,8 @@ public partial class TransportImport : ComponentBase, IDisposable
using var stream = new FileStream(
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
- _session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
+ _session = await BundleImporter.LoadAsync(
+ stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
_errorMessage = null;
}
catch (ArgumentException)
@@ -253,6 +259,14 @@ public partial class TransportImport : ComponentBase, IDisposable
_session = null;
throw;
}
+ catch (BundleUnlockThrottledException)
+ {
+ // Register row 44: per-IP-per-hour budget spent. Bubble so the caller
+ // can surface it WITHOUT counting a passphrase failure — the attempt
+ // never reached the decryptor.
+ _session = null;
+ throw;
+ }
catch (InvalidDataException ex)
{
_session = null;
@@ -330,6 +344,11 @@ public partial class TransportImport : ComponentBase, IDisposable
/// 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.
///
+ ///
+ /// Register row 44: a is a third,
+ /// distinct outcome — this CLIENT has spent its per-IP-per-hour unlock budget.
+ /// It burns no per-bundle strike and leaves the wizard on Step 2.
+ ///
///
private async Task SubmitPassphraseAsync()
{
@@ -360,6 +379,19 @@ public partial class TransportImport : ComponentBase, IDisposable
ResetSessionState();
_step = ImportWizardStep.Upload;
}
+ catch (BundleUnlockThrottledException ex)
+ {
+ // Register row 44: fail closed on the per-IP-per-hour cap. Deliberately
+ // does NOT touch _failedUnlockAttempts — a throttled attempt never
+ // reached the decryptor, so it must not consume one of the bundle's
+ // three strikes (otherwise the throttle could be used to lock a bundle
+ // an operator is legitimately importing). The wizard stays on Step 2 so
+ // the operator can retry once the hour window rolls forward.
+ _passphrase = string.Empty;
+ var throttledEntity = _session?.Manifest.ContentHash ?? "";
+ await EmitUnlockFailedAuditRowAsync(throttledEntity, _failedUnlockAttempts, ex.Message);
+ _errorMessage = ex.Message;
+ }
catch (CryptographicException ex)
{
_failedUnlockAttempts++;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index 2509313a..4cbada93 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -27,6 +27,13 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddCascadingAuthenticationState();
+ // Snapshots the circuit-establishing request's remote IP (HttpContext is only
+ // valid then), so circuit-lifetime code can still attribute a client address —
+ // today the Transport import wizard's per-IP unlock throttle. Explicit factory
+ // because the type carries a second (test-seam) string ctor.
+ services.AddScoped(sp => new CircuitClientAddressProvider(
+ sp.GetRequiredService()));
+
// Resolves the current user's permitted site set from their SiteId claims
// so Deployment/Monitoring pages can enforce site scoping.
services.AddScoped();
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
index 3990348d..4e6d6176 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
@@ -10,8 +10,20 @@ public interface IBundleImporter
/// Stream containing the bundle zip archive.
/// Optional passphrase for decrypting an encrypted bundle.
/// Cancellation token.
+ ///
+ /// Identity of the calling client — the remote IP — used to enforce the
+ /// per-IP-per-hour unlock throttle (TransportOptions.MaxUnlockAttemptsPerIpPerHour)
+ /// on encrypted bundles. Trailing optional so unencrypted / in-process callers
+ /// need not supply one; null or blank counts the attempt against a single shared
+ /// "unattributed caller" bucket rather than skipping the throttle, so a caller
+ /// that cannot be attributed can never spend an unbounded budget.
+ ///
/// A task that resolves to session metadata for the loaded bundle.
- Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
+ Task LoadAsync(
+ Stream bundleStream,
+ string? passphrase,
+ CancellationToken ct = default,
+ string? clientKey = null);
///
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementEnvelope.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementEnvelope.cs
index 9c5915f7..e674518e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementEnvelope.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementEnvelope.cs
@@ -4,7 +4,22 @@ public record AuthenticatedUser(
string Username, string DisplayName,
string[] Roles, string[] PermittedSiteIds);
-public record ManagementEnvelope(AuthenticatedUser User, object Command, string CorrelationId);
+/// The authenticated caller.
+/// The management command to run.
+/// Per-request correlation id echoed on the reply.
+///
+/// Remote IP of the HTTP caller, when the envelope came in over the management
+/// endpoint. Additive trailing-optional (message contracts evolve additively), so
+/// in-cluster senders that construct an envelope directly stay source-compatible and
+/// simply carry null. Consumed by the Transport bundle handlers to enforce the
+/// per-IP-per-hour unlock throttle; null degrades to a shared throttle bucket rather
+/// than to no throttling (register row 44).
+///
+public record ManagementEnvelope(
+ AuthenticatedUser User,
+ object Command,
+ string CorrelationId,
+ string? ClientIp = null);
public record ManagementSuccess(string CorrelationId, string JsonData);
public record ManagementError(string CorrelationId, string Error, string ErrorCode);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleUnlockThrottledException.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleUnlockThrottledException.cs
new file mode 100644
index 00000000..6a63e52d
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleUnlockThrottledException.cs
@@ -0,0 +1,43 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
+
+///
+/// T-004 / register row 44: thrown by IBundleImporter.LoadAsync when a client has
+/// spent its trailing-hour bundle-unlock budget (TransportOptions.MaxUnlockAttemptsPerIpPerHour,
+/// default 10) as measured by BundleUnlockRateLimiter.
+///
+/// Distinct from BundleLockedException on purpose: that one means "this BUNDLE has
+/// burned its 3 strikes" and is keyed by the bundle's content hash; this one means "this
+/// CLIENT has attempted too many unlocks, across any bundles" and is keyed by the caller's
+/// IP. A throttled attempt never reaches the decryptor, so it deliberately burns no
+/// per-bundle strike — otherwise the throttle could be used to lock out a bundle an
+/// operator is legitimately importing.
+///
+///
+/// Lives in Commons rather than beside the importer because it is part of the
+/// IBundleImporter.LoadAsync contract, which is declared here: the management
+/// surface (which references Commons but not Transport) must be able to catch it to
+/// return a clear, curated error instead of a generic internal-failure message.
+///
+///
+public sealed class BundleUnlockThrottledException : Exception
+{
+ /// The rate-limiter key the attempt was counted against — the client IP, or the shared unattributed-caller bucket.
+ public string ClientKey { get; }
+
+ /// The configured trailing-window cap that was reached.
+ public int MaxAttemptsPerWindow { get; }
+
+ ///
+ /// Initializes a new .
+ ///
+ /// The rate-limiter key (client IP, or the shared unattributed bucket).
+ /// The configured per-window cap that was reached.
+ public BundleUnlockThrottledException(string clientKey, int maxAttemptsPerWindow)
+ : base(
+ $"Too many bundle unlock attempts ({maxAttemptsPerWindow} per hour) from this client. "
+ + "Wait for the hour window to roll forward before trying again.")
+ {
+ ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
+ MaxAttemptsPerWindow = maxAttemptsPerWindow;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index 5dfbaba0..5a6c8f66 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -130,7 +130,7 @@ public class ManagementActor : ReceiveActor
private async Task
private static async Task HandlePreviewBundle(
- IServiceProvider sp, PreviewBundleCommand cmd)
+ IServiceProvider sp, PreviewBundleCommand cmd, string? clientIp)
{
await TransportGate.WaitAsync();
try
@@ -3560,7 +3561,7 @@ public class ManagementActor : ReceiveActor
try
{
await using var stream = new MemoryStream(bytes);
- session = await importer.LoadAsync(stream, cmd.Passphrase);
+ session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
}
catch (ArgumentException ex)
{
@@ -3570,6 +3571,13 @@ public class ManagementActor : ReceiveActor
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
+ catch (BundleUnlockThrottledException ex)
+ {
+ // Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
+ // prints the reason instead of MapFault's generic internal-error
+ // message; no per-bundle strike was burned.
+ throw new ManagementCommandException(ex.Message);
+ }
// Register row 43: a preview NEVER applies, so nothing else would ever
// release this session — every `transport preview` used to burn one of
@@ -3605,7 +3613,7 @@ public class ManagementActor : ReceiveActor
/// rows fail the call before Apply.
///
private static async Task HandleImportBundle(
- IServiceProvider sp, ImportBundleCommand cmd, string username)
+ IServiceProvider sp, ImportBundleCommand cmd, string username, string? clientIp)
{
await TransportGate.WaitAsync();
try
@@ -3618,7 +3626,7 @@ public class ManagementActor : ReceiveActor
try
{
await using var stream = new MemoryStream(bytes);
- session = await importer.LoadAsync(stream, cmd.Passphrase);
+ session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
}
catch (ArgumentException ex)
{
@@ -3628,6 +3636,13 @@ public class ManagementActor : ReceiveActor
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
+ catch (BundleUnlockThrottledException ex)
+ {
+ // Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
+ // prints the reason instead of MapFault's generic internal-error
+ // message; no per-bundle strike was burned.
+ throw new ManagementCommandException(ex.Message);
+ }
// Register row 43: ApplyAsync releases the session on both its success
// and failure paths, but every abort BEFORE it (blockers, unresolved
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
index fab85847..0d70ea5a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
@@ -134,7 +134,14 @@ public static class ManagementEndpoints
}
var correlationId = Guid.NewGuid().ToString("N");
- var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
+ // Carry the caller's remote IP so the Transport bundle handlers can enforce
+ // the per-IP-per-hour unlock throttle (register row 44). ForwardedHeaders has
+ // already rewritten this from X-Forwarded-For for CONFIGURED proxies only
+ // (Program.cs), so it is a real client address in the shipped topologies and
+ // the proxy's address behind an untrusted one.
+ var envelope = new ManagementEnvelope(
+ authenticatedUser, command, correlationId,
+ context.Connection.RemoteIpAddress?.ToString());
var askTimeout = ResolveAskTimeout(
context.RequestServices.GetService>()?.Value,
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index ffbc646c..c4263069 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -165,8 +165,22 @@ public sealed class BundleImporter : IBundleImporter
_logger = logger;
}
+ ///
+ /// T-004 / register row 44: the rate-limiter bucket used when a caller supplies no
+ /// client key. Deliberately a single SHARED key rather than a per-call bypass — an
+ /// unattributable caller is throttled together with every other unattributable
+ /// caller, so "no IP available" degrades to a stricter budget, never to none. A
+ /// production unlock always carries an IP (Central UI circuit, management endpoint),
+ /// so in practice this bucket only serves in-process callers and tests.
+ ///
+ internal const string UnattributedClientKey = "";
+
///
- public async Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
+ public async Task LoadAsync(
+ Stream bundleStream,
+ string? passphrase,
+ CancellationToken ct = default,
+ string? clientKey = null)
{
ArgumentNullException.ThrowIfNull(bundleStream);
@@ -264,6 +278,33 @@ public sealed class BundleImporter : IBundleImporter
throw new BundleLockedException(manifest.ContentHash, priorFailures);
}
+ // T-004 / register row 44: the documented per-IP-per-hour cap. The limiter
+ // was registered, injected and unit-tested but never invoked, so a
+ // brute-force attacker against a stolen bundle was bounded only by the
+ // per-bundle 3-strike lockout (side-steppable by re-exporting the bundle
+ // for a fresh ContentHash).
+ //
+ // Ordering is load-bearing:
+ // * AFTER the per-bundle lockout check, so hammering an already-locked
+ // bundle does not spend the client's hourly budget on rejected calls.
+ // * BEFORE the decrypt, so a THROTTLED attempt never reaches
+ // IncrementUnlockFailureCount — a denial must not burn a per-bundle
+ // strike, or the throttle would become a way to lock a bundle that an
+ // operator is legitimately importing.
+ // Registered on every unlock attempt (successful ones included) because the
+ // documented budget counts attempts, not failures.
+ var throttleKey = string.IsNullOrWhiteSpace(clientKey)
+ ? UnattributedClientKey
+ : clientKey;
+ var perIpCap = _options.Value.MaxUnlockAttemptsPerIpPerHour;
+ if (!_unlockRateLimiter.TryRegisterAttempt(throttleKey, perIpCap))
+ {
+ _logger?.LogWarning(
+ "Bundle unlock throttled: client {ClientKey} exceeded {Cap} attempts in the trailing hour.",
+ throttleKey, perIpCap);
+ throw new BundleUnlockThrottledException(throttleKey, perIpCap);
+ }
+
var aad = Encryption.BundleManifestAad.Compute(manifest);
try
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs
index 0848a963..74e4f21f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -45,6 +46,9 @@ public class TransportImportPageTests : BunitContext
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For();
+ /// Client address the wizard reports for the per-IP unlock throttle (register row 44).
+ private const string TestClientIp = "203.0.113.7";
+
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -52,6 +56,11 @@ public class TransportImportPageTests : BunitContext
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
+ // Register row 44: the wizard passes the circuit's client IP to LoadAsync so
+ // the per-IP unlock throttle can attribute the attempt. bUnit has no HTTP
+ // context, so use the provider's explicit-address test seam.
+ Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp));
+
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(Array.Empty());
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any())
@@ -132,7 +141,7 @@ public class TransportImportPageTests : BunitContext
public async Task Decryption_failure_increments_attempt_counter()
{
// Set up the importer to throw CryptographicException for wrong passphrases.
- _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render();
@@ -162,7 +171,7 @@ public class TransportImportPageTests : BunitContext
[Fact]
public async Task Three_failed_unlocks_force_reupload()
{
- _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render();
@@ -725,7 +734,8 @@ public class TransportImportPageTests : BunitContext
return Store.Open(session);
}
- public Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
+ public Task LoadAsync(
+ Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
public Task PreviewAsync(Guid sessionId, CancellationToken ct = default)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
index 7d21bdd1..5026caf6 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
@@ -234,6 +234,10 @@ public sealed class QueryStringDrillInTests
Services.AddSingleton(Substitute.For());
// M8 E2: TransportImport's Map step injects ISiteRepository.
Services.AddSingleton(Substitute.For());
+ // Register row 44: the wizard reads the circuit's client IP for the
+ // per-IP unlock throttle. No HTTP context under bUnit — use the seam.
+ Services.AddSingleton(new ZB.MOM.WW.ScadaBridge.CentralUI.Auth.CircuitClientAddressProvider(
+ clientAddress: null));
Services.AddSingleton>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index 66366757..84ff8003 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -2734,7 +2734,7 @@ public class ManagementActorTests : TestKit, IDisposable
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
- importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
@@ -2787,7 +2787,7 @@ public class ManagementActorTests : TestKit, IDisposable
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
- importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
@@ -2865,7 +2865,7 @@ public class ManagementActorTests : TestKit, IDisposable
IReadOnlyList connectionMappings)
{
var sessionId = Guid.NewGuid();
- importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
@@ -3089,7 +3089,7 @@ public class ManagementActorTests : TestKit, IDisposable
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
- importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
@@ -3126,6 +3126,99 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("OpcA", success.JsonData);
}
+ [Fact]
+ public void PreviewBundleCommand_ReleasesTheSession_AndForwardsTheCallersIp()
+ {
+ // Register row 43: a preview never applies, so nothing else releases the
+ // session — it used to hold one of MaxConcurrentImportSessions for the full
+ // TTL. Register row 44: the envelope's client IP must reach LoadAsync so the
+ // per-IP-per-hour unlock throttle can attribute the attempt.
+ var (_, importer) = AddBundleSubstitutes();
+
+ var sessionId = Guid.NewGuid();
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new Commons.Types.Transport.BundleSession
+ {
+ SessionId = sessionId,
+ Manifest = null!,
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
+ });
+ importer.PreviewAsync(sessionId, Arg.Any())
+ .Returns(new Commons.Types.Transport.ImportPreview(
+ sessionId,
+ Array.Empty()));
+
+ var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
+ var actor = CreateActor();
+ var envelope = Envelope(new PreviewBundleCommand(payload, null), "Administrator")
+ with { ClientIp = "198.51.100.9" };
+
+ actor.Tell(envelope);
+
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ importer.Received(1).LoadAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(), "198.51.100.9");
+ importer.Received(1).Abandon(sessionId);
+ }
+
+ [Fact]
+ public void ImportBundleCommand_WithBlockerRow_StillReleasesTheSession()
+ {
+ // Register row 43: ApplyAsync releases the session, but this import aborts
+ // BEFORE apply — the handler must release it anyway.
+ var (_, importer) = AddBundleSubstitutes();
+
+ var sessionId = Guid.NewGuid();
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new Commons.Types.Transport.BundleSession
+ {
+ SessionId = sessionId,
+ Manifest = null!,
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
+ });
+ importer.PreviewAsync(sessionId, Arg.Any())
+ .Returns(new Commons.Types.Transport.ImportPreview(
+ sessionId,
+ new[]
+ {
+ new Commons.Types.Transport.ImportPreviewItem(
+ EntityType: "Template",
+ Name: "BlockedTemplate",
+ ExistingVersion: null,
+ IncomingVersion: 1,
+ Kind: Commons.Types.Transport.ConflictKind.Blocker,
+ FieldDiffJson: null,
+ BlockerReason: "FK to missing site"),
+ }));
+
+ var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
+ var actor = CreateActor();
+ actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
+
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ importer.Received(1).Abandon(sessionId);
+ }
+
+ [Fact]
+ public void ImportBundleCommand_ThrottledUnlock_SurfacesTheCuratedReason()
+ {
+ // Register row 44: a per-IP throttle denial must fail closed with a clear
+ // message, not MapFault's generic "an internal error occurred".
+ var (_, importer) = AddBundleSubstitutes();
+
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(_ =>
+ throw new Commons.Types.Transport.BundleUnlockThrottledException("198.51.100.9", 10));
+
+ var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
+ var actor = CreateActor();
+ actor.Tell(Envelope(new ImportBundleCommand(payload, "pass", "skip"), "Administrator"));
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal("COMMAND_FAILED", response.ErrorCode);
+ Assert.Contains("Too many bundle unlock attempts", response.Error);
+ }
+
// ========================================================================
// Native alarm source CRUD (Task 21)
// ========================================================================
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs
index 7b84db27..c687570e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs
@@ -53,7 +53,7 @@ public class TransportGateTests : TestKit, IDisposable
var loadCalls = 0;
var session = new BundleSession { SessionId = Guid.NewGuid() };
- _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(_ =>
{
Interlocked.Increment(ref loadCalls);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs
index 2148f7f0..d901fa92 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs
@@ -502,6 +502,183 @@ public sealed class BundleImporterLoadTests
() => rig.Importer.LoadAsync(stream, passphrase: null));
}
+ // ─────────────────────────────────────────────────────────────────────
+ // Register row 44 — the per-IP-per-hour unlock throttle is actually enforced.
+ // BundleUnlockRateLimiter was registered, injected and unit-tested but never
+ // invoked, so these all passed-by-doing-nothing before the wiring.
+ // ─────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap()
+ {
+ // Per-bundle strikes are effectively disabled here (100) so the throttle is
+ // unambiguously what stops the run.
+ var rig = BuildRig(opts =>
+ {
+ opts.MaxUnlockAttemptsPerIpPerHour = 3;
+ opts.MaxUnlockAttemptsPerSession = 100;
+ });
+ using var packed = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ await Assert.ThrowsAnyAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
+ }
+
+ var throttled = await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
+ Assert.Equal(3, throttled.MaxAttemptsPerWindow);
+ Assert.Equal("10.1.1.5", throttled.ClientKey);
+
+ // Fail closed: even the CORRECT passphrase is refused while the client is
+ // over budget.
+ await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5"));
+ }
+
+ [Fact]
+ public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike()
+ {
+ // A throttled attempt never reaches the decryptor, so it must not consume one
+ // of the bundle's three strikes — otherwise the per-IP throttle would become a
+ // way to lock a bundle that an operator is legitimately importing.
+ var rig = BuildRig(opts =>
+ {
+ opts.MaxUnlockAttemptsPerIpPerHour = 1;
+ opts.MaxUnlockAttemptsPerSession = 3;
+ });
+ using var packed = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+ var contentHash = rig.Serializer.ReadManifest(new MemoryStream(bundleBytes)).ContentHash;
+
+ await Assert.ThrowsAnyAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
+ Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
+
+ await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
+
+ // Still 1 — the denial burned no strike, so the bundle is not locked.
+ Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
+ }
+
+ [Fact]
+ public async Task Unlock_throttle_is_isolated_per_client()
+ {
+ // One noisy client must not spend another operator's budget.
+ var rig = BuildRig(opts =>
+ {
+ opts.MaxUnlockAttemptsPerIpPerHour = 1;
+ opts.MaxUnlockAttemptsPerSession = 100;
+ });
+ using var packed = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+
+ await Assert.ThrowsAnyAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
+ await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
+
+ // A different client still has its full budget — reaches the decryptor and
+ // fails on the passphrase, not on the throttle.
+ await Assert.ThrowsAnyAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8"));
+ }
+
+ [Fact]
+ public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it()
+ {
+ // Conservative fallback: a caller with no IP is counted against a single
+ // shared bucket, so "unknown client" is a stricter budget, never none.
+ var rig = BuildRig(opts =>
+ {
+ opts.MaxUnlockAttemptsPerIpPerHour = 1;
+ opts.MaxUnlockAttemptsPerSession = 100;
+ });
+ using var packed = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+
+ await Assert.ThrowsAnyAsync(
+ () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
+
+ // Blank is treated the same as null — both land in the shared bucket, which
+ // the first attempt already emptied.
+ var throttled = await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: " "));
+ Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey);
+ }
+
+ [Fact]
+ public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget()
+ {
+ // The throttle guards passphrase brute-forcing; a plain bundle has no unlock
+ // step, so repeated loads must not be able to exhaust an operator's budget.
+ var rig = BuildRig(opts => opts.MaxUnlockAttemptsPerIpPerHour = 1);
+ using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+
+ for (var i = 0; i < 3; i++)
+ {
+ var session = await rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: null, clientKey: "10.1.1.9");
+ Assert.NotNull(session);
+ rig.Importer.Abandon(session.SessionId);
+ }
+ }
+
+ [Fact]
+ public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget()
+ {
+ // Ordering pin: the per-bundle lockout is checked BEFORE the throttle, so
+ // hammering a locked bundle cannot burn through the client's hourly budget
+ // and spill over into denying its next legitimate import.
+ var rig = BuildRig(opts =>
+ {
+ opts.MaxUnlockAttemptsPerSession = 1;
+ opts.MaxUnlockAttemptsPerIpPerHour = 2;
+ });
+ using var packed = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
+ var bundleBytes = ((MemoryStream)packed).ToArray();
+
+ // One wrong attempt locks the bundle (cap 1) and spends 1 of 2 IP budget.
+ await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
+
+ // Three more attempts on the locked bundle stay BundleLocked — none of them
+ // register against the rate limiter.
+ for (var i = 0; i < 3; i++)
+ {
+ await Assert.ThrowsAsync(
+ () => rig.Importer.LoadAsync(
+ new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
+ }
+
+ // A DIFFERENT bundle from the same client still has budget left.
+ using var other = PackEncryptedBundle(
+ rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct2");
+ var otherSession = await rig.Importer.LoadAsync(
+ new MemoryStream(((MemoryStream)other).ToArray()),
+ passphrase: "correct2", clientKey: "10.1.1.10");
+ Assert.NotNull(otherSession);
+ }
+
// ─────────────────────────────────────────────────────────────────────
// Register row 43 — abandoned-session release.
// ─────────────────────────────────────────────────────────────────────