205a06a0f1
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
167 lines
7.4 KiB
C#
167 lines
7.4 KiB
C#
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);
|
|
}
|
|
}
|