Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/LegacyBundleCompatibilityTests.cs
T
Joseph Doherty 205a06a0f1 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
2026-11-09 17:42:28 -05:00

208 lines
11 KiB
C#

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);
}
}
}