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; /// /// ScadaBridge#45: enforces the signature policy on a /// real packed bundle, and a signed bundle stays readable by the pre-signing read path. /// public sealed class SignedBundleLoadTests { private const string Passphrase = "correct horse battery"; private static BundleContentDto Content() => new( TemplateFolders: Array.Empty(), Templates: new[] { new TemplateDto("Pump", null, null, "signed", Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()), }, SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()); 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(), 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(), externalRepo: Substitute.For(), notificationRepo: Substitute.For(), inboundApiRepo: Substitute.For(), siteRepo: Substitute.For(), auditService: Substitute.For(), correlationContext: Substitute.For(), dbContext: new ScadaBridgeDbContext(new DbContextOptionsBuilder().Options), semanticValidator: new SemanticValidator(), signer: BundleSignerTests.Signer(opts)); } private static byte[] ReplaceEntry(byte[] bundle, string entryName, Func 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(); } /// A signed bundle — plaintext or encrypted — loads as Verified on a cluster that trusts the key. [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); } /// /// Re-labelling a signed bundle's manifest (here, its source environment) — which the /// content hash alone cannot catch for a plaintext bundle — is refused. /// [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(() => importer.LoadAsync(new MemoryStream(tampered), null)); } /// /// 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. /// [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(() => importer.LoadAsync(new MemoryStream(bundle), null)); } /// By default an unsigned bundle loads unchanged and reports Unsigned. [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); } /// /// 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. /// [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)); } }