Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/SignedBundleLoadTests.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

194 lines
9.2 KiB
C#

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