feat(transport): optional detached bundle signing with a pluggable key source (ScadaBridge#45)

Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.

- The bundle format does not change: bundleFormatVersion stays 1,
  manifest.json and the AES-GCM associated data are untouched, and older
  importers open entries by name and ignore manifest.sig. Three entries fit
  within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
  work. A signature that fails to verify against a trusted key is always
  refused. RequireSignedBundles additionally refuses unsigned bundles and
  bundles signed by an untrusted key. A refusal is BundleSignatureException
  (Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
  shown in the import wizard. They are also returned on PreviewBundleResult
  and recorded on the BundleImported audit row. BundleExported records
  Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
  are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
  ${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
  fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
  pre-signing-encrypted were exported by origin/main 3ff463b6, before this
  change, and are imported end to end with signing off and with signing
  configured.

The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:31:55 -05:00
parent 1ae4c7a4cb
commit 205a06a0f1
26 changed files with 1528 additions and 11 deletions
@@ -935,6 +935,74 @@ public class TransportImportPageTests : BunitContext
// widening the surface of the production class with test-only accessors.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// ScadaBridge#45: a signature-policy refusal is shown as its own reason and is NOT
/// counted as a wrong passphrase (the importer checks the signature before decrypting).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Signature_rejection_shows_reason_without_counting_a_failed_unlock()
{
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Throws(new BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
SetField(cut.Instance, "_passphrase", "any");
});
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync"));
Assert.Equal(0, GetField<int>(cut.Instance, "_failedUnlockAttempts"));
var error = GetField<string?>(cut.Instance, "_errorMessage");
Assert.NotNull(error);
Assert.Contains("Bundle signature rejected", error);
Assert.Contains("requires signed bundles", error);
}
/// <summary>ScadaBridge#45: the upload step's manifest summary shows the bundle's signature status.</summary>
/// <param name="status">The session's signature status.</param>
/// <param name="expected">Text the Signature row must contain.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(BundleSignatureStatus.Verified, "Verified")]
[InlineData(BundleSignatureStatus.UntrustedKey, "untrusted key")]
[InlineData(BundleSignatureStatus.Unsigned, "Unsigned")]
public async Task Manifest_summary_shows_signature_status(BundleSignatureStatus status, string expected)
{
var cut = Render<TransportImportPage>();
var session = BuildEncryptedSession();
session = new BundleSession
{
SessionId = session.SessionId,
Manifest = session.Manifest,
DecryptedContent = session.DecryptedContent,
ExpiresAt = session.ExpiresAt,
SignatureStatus = status,
SignerKeyId = status == BundleSignatureStatus.Unsigned ? null : "sha256:feed",
SignerName = status == BundleSignatureStatus.Verified ? "dev-cluster" : null,
};
await cut.InvokeAsync(() =>
{
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01 });
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Upload);
SetField(cut.Instance, "_session", session);
});
cut.Render();
var row = cut.Find("[data-testid=manifest-signature]");
Assert.Contains(expected, row.TextContent);
if (status == BundleSignatureStatus.Verified)
{
Assert.Contains("dev-cluster", row.TextContent);
}
if (status == BundleSignatureStatus.UntrustedKey)
{
Assert.Contains("sha256:feed", row.TextContent);
}
}
private static void SetField(object obj, string name, object? value)
{
var field = obj.GetType().GetField(
@@ -4669,6 +4669,63 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.DoesNotContain("An internal error occurred", response.Error);
}
/// <summary>ScadaBridge#45: a signature-policy refusal reaches the CLI as the curated reason, on preview and import.</summary>
[Fact]
public void BundleCommands_SignatureRejected_SurfaceTheCuratedReason()
{
var (_, importer) = AddBundleSubstitutes();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns<Commons.Types.Transport.BundleSession>(_ =>
throw new Commons.Types.Transport.BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
var preview = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
var import = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
foreach (var response in new[] { preview, import })
{
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires signed bundles", response.Error);
Assert.DoesNotContain("An internal error occurred", response.Error);
}
}
/// <summary>ScadaBridge#45: the preview result carries the bundle's signature status and signer.</summary>
[Fact]
public void PreviewBundleCommand_ReportsSignatureStatus()
{
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
SignatureStatus = Commons.Types.Transport.BundleSignatureStatus.Verified,
SignerKeyId = "sha256:abc",
SignerName = "dev-cluster",
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId, Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(Convert.ToBase64String(new byte[] { 1 }), null), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = System.Text.Json.JsonDocument.Parse(response.JsonData);
var root = doc.RootElement;
string Prop(string name) => root.EnumerateObject()
.Single(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)).Value.GetString()!;
Assert.Equal("Verified", Prop("SignatureStatus"));
Assert.Equal("sha256:abc", Prop("SignerKeyId"));
Assert.Equal("dev-cluster", Prop("SignerName"));
}
// ========================================================================
// Native alarm source CRUD
// ========================================================================
@@ -0,0 +1,207 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// ScadaBridge#45 — bundle-format compatibility. The two bundles under <c>Fixtures/</c> were
/// exported by the Transport code from BEFORE bundle signing existed (<c>origin/main</c>
/// <c>3ff463b6</c>, real <c>AddTransport()</c> pipeline over in-memory EF, the
/// <see cref="RoundTripTests"/> seed: templates <c>BaseDevice</c> / <c>Pump</c> (composes
/// <c>BaseDevice</c> as <c>base</c>) / <c>Standalone</c>, shared script <c>HelperFn</c>,
/// external system <c>ErpSystem</c>, notification list <c>OnCall</c>; source environment
/// <c>legacy-env</c>). <c>pre-signing-plaintext</c> is unencrypted; <c>pre-signing-encrypted</c>
/// uses the passphrase <see cref="LegacyPassphrase"/>.
/// <para>
/// <b>Never regenerate these files</b> — their value is that old code wrote them. When the
/// format changes again, add a new fixture beside them.
/// </para>
/// </summary>
public sealed class LegacyBundleCompatibilityTests
{
private const string LegacyPassphrase = "legacy-fixture-passphrase";
private static ServiceProvider BuildProvider(IDictionary<string, string?> settings)
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
var dbName = $"LegacyBundleCompat_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
return services.BuildServiceProvider();
}
/// <summary>Signing configured (key + trusted keys) but not required — the realistic rollout state.</summary>
private static Dictionary<string, string?> SigningConfigured()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return new Dictionary<string, string?>
{
["ScadaBridge:Transport:Signing:Enabled"] = "true",
["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(),
["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self",
["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(),
};
}
private static byte[] Fixture(string name) =>
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
public static TheoryData<string, string?, bool> Cases() => new()
{
{ "pre-signing-plaintext.scadabundle", null, false },
{ "pre-signing-encrypted.scadabundle", LegacyPassphrase, false },
{ "pre-signing-plaintext.scadabundle", null, true },
{ "pre-signing-encrypted.scadabundle", LegacyPassphrase, true },
};
/// <summary>
/// A bundle produced before signing existed loads, previews and applies in full — with
/// signing left at its defaults and with signing configured (not required).
/// </summary>
[Theory]
[MemberData(nameof(Cases))]
public async Task Pre_signing_bundle_still_imports(string fixture, string? passphrase, bool signingConfigured)
{
await using var provider = BuildProvider(
signingConfigured ? SigningConfigured() : new Dictionary<string, string?>());
BundleSession session;
await using (var scope = provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
session = await importer.LoadAsync(new MemoryStream(Fixture(fixture)), passphrase);
}
Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus);
Assert.Equal("legacy-env", session.Manifest.SourceEnvironment);
ImportResult result;
await using (var scope = provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var preview = await importer.PreviewAsync(session.SessionId);
Assert.Equal(6, preview.Items.Count);
Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind));
var resolutions = preview.Items
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
.ToList();
result = await importer.ApplyAsync(session.SessionId, resolutions, user: "bob");
}
Assert.Equal(6, result.Added);
await using (var scope = provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var baseDevice = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "BaseDevice");
Assert.Equal("init", Assert.Single(baseDevice.Scripts).Name);
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
Assert.Equal(baseDevice.Id, Assert.Single(pump.Compositions).ComposedTemplateId);
Assert.Equal("return 1;", (await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn")).Code);
Assert.True(await ctx.ExternalSystemDefinitions.AnyAsync(e => e.Name == "ErpSystem"));
Assert.True(await ctx.NotificationLists.AnyAsync(n => n.Name == "OnCall"));
var imported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImported");
Assert.Contains("\"SignatureStatus\":\"Unsigned\"", imported.AfterStateJson!, StringComparison.Ordinal);
}
}
/// <summary>
/// With <c>RequireSignedBundles</c> on, the same pre-signing bundle is refused — the flag is
/// the one deliberate way to stop accepting old bundles.
/// </summary>
[Fact]
public async Task Pre_signing_bundle_is_refused_when_signing_required()
{
var settings = SigningConfigured();
settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true";
await using var provider = BuildProvider(settings);
await using var scope = provider.CreateAsyncScope();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<BundleSignatureException>(() => importer.LoadAsync(
new MemoryStream(Fixture("pre-signing-encrypted.scadabundle")), LegacyPassphrase));
}
/// <summary>
/// End to end through DI: with signing enabled the exporter writes a signed, encrypted bundle
/// (and audits the key), and an importer that trusts the key loads it as Verified and
/// records the signer on the BundleImported row.
/// </summary>
[Fact]
public async Task Signed_export_imports_as_Verified_through_DI()
{
var settings = SigningConfigured();
settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true";
await using var provider = BuildProvider(settings);
await using (var scope = provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.SharedScripts.Add(new SharedScript("Signed", "return 2;"));
ctx.Templates.Add(new Template("SignedTemplate"));
await ctx.SaveChangesAsync();
}
byte[] bundle;
await using (var scope = provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var selection = new ExportSelection(
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
var stream = await scope.ServiceProvider.GetRequiredService<IBundleExporter>()
.ExportAsync(selection, "alice", "signing-env", "pw-for-signed-bundle");
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundle = ms.ToArray();
var exported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleExported");
Assert.Contains("\"Signed\":true", exported.AfterStateJson!, StringComparison.Ordinal);
}
await using (var scope = provider.CreateAsyncScope())
{
var keyId = scope.ServiceProvider.GetRequiredService<IBundleSigningKeySource>().GetSigningKey()!.KeyId;
var session = await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
.LoadAsync(new MemoryStream(bundle), "pw-for-signed-bundle");
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
Assert.Equal("self", session.SignerName);
Assert.Equal(keyId, session.SignerKeyId);
scope.ServiceProvider.GetRequiredService<IBundleImporter>().Abandon(session.SessionId);
}
}
}
@@ -30,6 +30,12 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- ScadaBridge#45: bundles exported by the pre-signing code (origin/main 3ff463b6),
checked in byte-for-byte to prove later format changes still import them. -->
<None Include="Fixtures/*.scadabundle" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
@@ -0,0 +1,166 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing;
/// <summary>
/// ScadaBridge#45: the detached-signature mechanism and the import-side policy over it.
/// </summary>
public sealed class BundleSignerTests
{
internal static (string PrivatePem, string PublicPem, string KeyId) NewKeyPair()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return (key.ExportPkcs8PrivateKeyPem(), key.ExportSubjectPublicKeyInfoPem(), BundleSigningKeys.ComputeKeyId(key));
}
internal static TransportOptions SigningOptions(
string? privatePem, bool require = false, params (string Name, string PublicPem)[] trusted)
{
var opts = new TransportOptions();
opts.Signing.Enabled = privatePem is not null;
opts.Signing.PrivateKey = privatePem;
opts.Signing.RequireSignedBundles = require;
foreach (var (name, pem) in trusted)
{
opts.Signing.TrustedKeys.Add(new TrustedBundleSigningKey { Name = name, PublicKey = pem });
}
return opts;
}
internal static BundleSigner Signer(TransportOptions opts)
{
var o = Options.Create(opts);
return new BundleSigner(new ConfiguredBundleSigningKeySource(o), new ConfiguredBundleTrustStore(o), o);
}
private static readonly byte[] Manifest = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"dev\"}");
/// <summary>A signature made with a key the importer trusts verifies and names the signer.</summary>
[Fact]
public void Sign_then_verify_with_trusted_key_is_Verified()
{
var (priv, pub, keyId) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
Assert.NotNull(sig);
var result = Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, sig);
Assert.Equal(BundleSignatureStatus.Verified, result.Status);
Assert.Equal(keyId, result.KeyId);
Assert.Equal("dev-cluster", result.SignerName);
}
/// <summary>Signing is off by default: no key, no signature entry.</summary>
[Fact]
public void Sign_returns_null_when_signing_disabled()
{
Assert.Null(Signer(new TransportOptions()).Sign(Manifest));
}
/// <summary>Any change to the signed manifest bytes is a hard rejection, required or not.</summary>
[Fact]
public void Verify_rejects_altered_manifest()
{
var (priv, pub, _) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
var altered = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"prd\"}");
var ex = Assert.Throws<BundleSignatureException>(
() => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(altered, sig));
Assert.Contains("modified after it was signed", ex.Message);
}
/// <summary>A signature claiming a trusted key id but made with another key does not verify.</summary>
[Fact]
public void Verify_rejects_signature_from_other_key_claiming_trusted_id()
{
var (_, pub, trustedId) = NewKeyPair();
var (otherPriv, _, _) = NewKeyPair();
using var other = ECDsa.Create();
other.ImportFromPem(otherPriv);
var forged = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new BundleSignatureEnvelope(
BundleSigningKeys.Algorithm, trustedId, Convert.ToBase64String(other.SignData(Manifest, HashAlgorithmName.SHA256))));
Assert.Throws<BundleSignatureException>(
() => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, forged));
}
/// <summary>Unsigned bundles load by default (every pre-signing bundle) and are refused when signing is required.</summary>
[Fact]
public void Unsigned_is_accepted_unless_required()
{
Assert.Equal(BundleSignatureStatus.Unsigned, Signer(new TransportOptions()).Verify(Manifest, null).Status);
Assert.Throws<BundleSignatureException>(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, null));
}
/// <summary>A signature by a key outside the trust store is reported, and refused when signing is required.</summary>
[Fact]
public void Untrusted_key_is_reported_unless_required()
{
var (priv, _, keyId) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
var result = Signer(new TransportOptions()).Verify(Manifest, sig);
Assert.Equal(BundleSignatureStatus.UntrustedKey, result.Status);
Assert.Equal(keyId, result.KeyId);
Assert.Null(result.SignerName);
Assert.Throws<BundleSignatureException>(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, sig));
}
/// <summary>Malformed signature entries and unknown algorithms are refused.</summary>
[Theory]
[InlineData("not json")]
[InlineData("{}")]
[InlineData("{\"Algorithm\":\"RSA-PSS\",\"KeyId\":\"sha256:00\",\"SignatureB64\":\"AA==\"}")]
public void Malformed_signature_entry_is_refused(string entry)
{
Assert.Throws<BundleSignatureException>(
() => Signer(new TransportOptions()).Verify(Manifest, Encoding.UTF8.GetBytes(entry)));
}
/// <summary>Keys load from PEM or bare base64 DER and must be P-256.</summary>
[Fact]
public void Key_loading_accepts_pem_and_base64_and_rejects_other_curves()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var fromB64 = BundleSigningKeys.LoadPrivateKey(Convert.ToBase64String(key.ExportPkcs8PrivateKey()));
using var pubB64 = BundleSigningKeys.LoadPublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()));
Assert.Equal(BundleSigningKeys.ComputeKeyId(key), BundleSigningKeys.ComputeKeyId(pubB64));
using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
Assert.Throws<CryptographicException>(() => BundleSigningKeys.LoadPrivateKey(p384.ExportPkcs8PrivateKeyPem()));
// A public key is not a signing key.
Assert.ThrowsAny<CryptographicException>(() => BundleSigningKeys.LoadPrivateKey(key.ExportSubjectPublicKeyInfoPem()));
}
/// <summary>Startup validation rejects an enabled-without-key or unparseable configuration and accepts a good one.</summary>
[Fact]
public void Options_validator_checks_signing_keys()
{
var validator = new TransportOptionsValidator();
var (priv, pub, _) = NewKeyPair();
var enabledNoKey = new TransportOptions();
enabledNoKey.Signing.Enabled = true;
var r1 = validator.Validate(Options.DefaultName, enabledNoKey);
Assert.True(r1.Failed);
Assert.Contains("Signing:PrivateKey", r1.FailureMessage);
var badKey = SigningOptions("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----");
Assert.True(validator.Validate(Options.DefaultName, badKey).Failed);
var badTrusted = SigningOptions(null, trusted: ("x", "not-a-key"));
var r3 = validator.Validate(Options.DefaultName, badTrusted);
Assert.True(r3.Failed);
Assert.Contains("TrustedKeys:0:PublicKey", r3.FailureMessage);
var good = SigningOptions(priv, require: true, trusted: ("dev", pub));
var r4 = validator.Validate(Options.DefaultName, good);
Assert.True(r4.Succeeded, r4.FailureMessage);
}
}
@@ -0,0 +1,193 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing;
/// <summary>
/// ScadaBridge#45: <see cref="BundleImporter.LoadAsync"/> enforces the signature policy on a
/// real packed bundle, and a signed bundle stays readable by the pre-signing read path.
/// </summary>
public sealed class SignedBundleLoadTests
{
private const string Passphrase = "correct horse battery";
private static BundleContentDto Content() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: new[]
{
new TemplateDto("Pump", null, null, "signed",
Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>()),
},
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static byte[] Pack(TransportOptions exporterOptions, string? passphrase)
{
var serializer = new BundleSerializer();
var content = Content();
var contentBytes = serializer.SerializeContentBytes(content);
var seed = passphrase is null
? null
: new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256", 100_000, string.Empty, string.Empty);
var manifest = new ManifestBuilder().Build("dev", "alice", "1.0.0", seed,
new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0), Array.Empty<ManifestContentEntry>(), contentBytes);
var signer = BundleSignerTests.Signer(exporterOptions);
using var zip = serializer.PackContentBytes(contentBytes, manifest, passphrase,
new BundleSecretEncryptor(), signer.Sign);
using var ms = new MemoryStream();
zip.CopyTo(ms);
return ms.ToArray();
}
private static BundleImporter Importer(TransportOptions opts)
{
var iOpts = Options.Create(opts);
return new BundleImporter(
bundleSerializer: new BundleSerializer(),
manifestValidator: new ManifestValidator(),
encryptor: new BundleSecretEncryptor(),
entitySerializer: new EntitySerializer(),
sessionStore: new BundleSessionStore(iOpts, TimeProvider.System),
unlockRateLimiter: new BundleUnlockRateLimiter(TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow),
options: iOpts,
timeProvider: TimeProvider.System,
templateRepo: Substitute.For<ITemplateEngineRepository>(),
externalRepo: Substitute.For<IExternalSystemRepository>(),
notificationRepo: Substitute.For<INotificationRepository>(),
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
siteRepo: Substitute.For<ISiteRepository>(),
auditService: Substitute.For<IAuditService>(),
correlationContext: Substitute.For<IAuditCorrelationContext>(),
dbContext: new ScadaBridgeDbContext(new DbContextOptionsBuilder<ScadaBridgeDbContext>().Options),
semanticValidator: new SemanticValidator(),
signer: BundleSignerTests.Signer(opts));
}
private static byte[] ReplaceEntry(byte[] bundle, string entryName, Func<byte[], byte[]> rewrite)
{
using var output = new MemoryStream();
using (var src = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read))
using (var dst = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in src.Entries)
{
using var es = entry.Open();
using var buf = new MemoryStream();
es.CopyTo(buf);
var bytes = entry.FullName == entryName ? rewrite(buf.ToArray()) : buf.ToArray();
using var ds = dst.CreateEntry(entry.FullName).Open();
ds.Write(bytes);
}
}
return output.ToArray();
}
/// <summary>A signed bundle — plaintext or encrypted — loads as Verified on a cluster that trusts the key.</summary>
[Theory]
[InlineData(null)]
[InlineData(Passphrase)]
public async Task Signed_bundle_loads_as_Verified(string? passphrase)
{
var (priv, pub, keyId) = BundleSignerTests.NewKeyPair();
var bundle = Pack(BundleSignerTests.SigningOptions(priv), passphrase);
var importer = Importer(BundleSignerTests.SigningOptions(null, require: true, trusted: ("dev-cluster", pub)));
var session = await importer.LoadAsync(new MemoryStream(bundle), passphrase);
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
Assert.Equal(keyId, session.SignerKeyId);
Assert.Equal("dev-cluster", session.SignerName);
Assert.Equal("Pump", Assert.Single(BundleImporter.ReadSessionContent(session).Templates).Name);
}
/// <summary>
/// Re-labelling a signed bundle's manifest (here, its source environment) — which the
/// content hash alone cannot catch for a plaintext bundle — is refused.
/// </summary>
[Fact]
public async Task Altered_manifest_of_signed_bundle_is_refused()
{
var (priv, pub, _) = BundleSignerTests.NewKeyPair();
var bundle = Pack(BundleSignerTests.SigningOptions(priv), passphrase: null);
var tampered = ReplaceEntry(bundle, "manifest.json", bytes =>
Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(bytes).Replace("\"dev\"", "\"prod\"", StringComparison.Ordinal)));
var importer = Importer(BundleSignerTests.SigningOptions(null, trusted: ("dev-cluster", pub)));
await Assert.ThrowsAsync<BundleSignatureException>(() => importer.LoadAsync(new MemoryStream(tampered), null));
}
/// <summary>
/// With signing required, an unsigned encrypted bundle is refused before the passphrase is
/// asked for — the policy check precedes the unlock, so it spends no unlock attempt.
/// </summary>
[Fact]
public async Task Required_signing_refuses_unsigned_bundle_before_passphrase()
{
var bundle = Pack(new TransportOptions(), Passphrase);
var importer = Importer(BundleSignerTests.SigningOptions(null, require: true));
await Assert.ThrowsAsync<BundleSignatureException>(() => importer.LoadAsync(new MemoryStream(bundle), null));
}
/// <summary>By default an unsigned bundle loads unchanged and reports Unsigned.</summary>
[Fact]
public async Task Default_policy_loads_unsigned_bundle()
{
var bundle = Pack(new TransportOptions(), Passphrase);
var session = await Importer(new TransportOptions()).LoadAsync(new MemoryStream(bundle), Passphrase);
Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus);
Assert.Null(session.SignerKeyId);
}
/// <summary>
/// Forward compatibility: a signed, encrypted bundle is still readable through the
/// pre-signing read path (open entries by name, AAD over the deserialised manifest) and
/// stays inside the default zip entry-count cap — so an importer from before #45 imports it.
/// </summary>
[Fact]
public void Signed_bundle_is_readable_by_pre_signing_read_path()
{
var (priv, _, _) = BundleSignerTests.NewKeyPair();
var bundle = Pack(BundleSignerTests.SigningOptions(priv), Passphrase);
using (var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read))
{
Assert.Equal(3, zip.Entries.Count);
Assert.True(zip.Entries.Count <= new TransportOptions().MaxBundleEntryCount);
Assert.NotNull(zip.GetEntry(BundleSerializer.SignatureEntryName));
}
var serializer = new BundleSerializer();
var manifest = serializer.ReadManifest(new MemoryStream(bundle));
var raw = serializer.ReadContentBytes(new MemoryStream(bundle), manifest);
Assert.Equal(ManifestValidationResult.Ok, new ManifestValidator().Validate(manifest, raw));
var content = serializer.UnpackContent(raw, manifest, Passphrase, new BundleSecretEncryptor());
Assert.Equal("Pump", Assert.Single(content.Templates).Name);
// The manifest JSON itself carries no signature field — nothing a pre-signing
// deserialiser or the AES-GCM associated data could trip on.
using var doc = JsonDocument.Parse(serializer.ReadManifestBytes(new MemoryStream(bundle)));
Assert.DoesNotContain(doc.RootElement.EnumerateObject(), p => p.Name.Contains("Sign", StringComparison.OrdinalIgnoreCase));
}
}