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