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
This commit is contained in:
Joseph Doherty
2026-11-09 17:31:55 -05:00
parent 1ae4c7a4cb
commit 205a06a0f1
26 changed files with 1528 additions and 11 deletions
@@ -119,6 +119,23 @@
<dt class="col-sm-3">SHA-256</dt>
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
<dt class="col-sm-3">Signature</dt>
<dd class="col-sm-9" data-testid="manifest-signature">
@switch (_session.SignatureStatus)
{
case BundleSignatureStatus.Verified:
<span class="text-success">Verified &mdash; signed by @_session.SignerName</span>
break;
case BundleSignatureStatus.UntrustedKey:
<span class="text-warning">Signed by an untrusted key</span>
<code class="small ms-1">@_session.SignerKeyId</code>
break;
default:
<span class="text-muted">Unsigned</span>
break;
}
</dd>
<dt class="col-sm-3">Encryption</dt>
<dd class="col-sm-9">
@if (_session.Manifest.Encryption is null)
@@ -298,6 +298,14 @@ public partial class TransportImport : ComponentBase, IDisposable
_session = null;
throw;
}
catch (BundleSignatureException ex)
{
// ScadaBridge#45: the signature policy refused the bundle (forged/altered
// signature, or an unsigned/untrusted bundle on a cluster that requires
// signing). Checked before the passphrase, so no unlock attempt was spent.
_session = null;
_errorMessage = $"Bundle signature rejected: {ex.Message}";
}
catch (InvalidDataException ex)
{
_session = null;
@@ -72,6 +72,19 @@ public sealed record PreviewBundleResult(
/// <summary>Connection references the operator must resolve before import. Never null.</summary>
public IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings { get; init; } =
RequiredConnectionMappings ?? Array.Empty<RequiredConnectionMapping>();
/// <summary>
/// ScadaBridge#45: the bundle's signature status — <c>Unsigned</c>, <c>Verified</c> or
/// <c>UntrustedKey</c> (the <c>BundleSignatureStatus</c> name). Additive; defaults to
/// <c>Unsigned</c>.
/// </summary>
public string SignatureStatus { get; init; } = nameof(BundleSignatureStatus.Unsigned);
/// <summary>ScadaBridge#45: the signing key's <c>sha256:</c> fingerprint; null when unsigned.</summary>
public string? SignerKeyId { get; init; }
/// <summary>ScadaBridge#45: the trusted key's name when the signature verified; otherwise null.</summary>
public string? SignerName { get; init; }
}
/// <summary>
@@ -23,6 +23,22 @@ public sealed class BundleSession
/// session. Empty until the bundle is successfully unlocked.
/// </summary>
public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
/// <summary>
/// ScadaBridge#45: outcome of the detached-signature check made when the bundle was
/// loaded. <see cref="BundleSignatureStatus.Unsigned"/> for every bundle produced before
/// signing existed.
/// </summary>
public BundleSignatureStatus SignatureStatus { get; init; } = BundleSignatureStatus.Unsigned;
/// <summary>
/// ScadaBridge#45: the <c>sha256:</c> fingerprint of the key that signed the bundle, when
/// it is signed (verified or not); <c>null</c> for an unsigned bundle.
/// </summary>
public string? SignerKeyId { get; init; }
/// <summary>
/// ScadaBridge#45: the trust-store name of the signing key when the signature verified;
/// <c>null</c> otherwise.
/// </summary>
public string? SignerName { get; init; }
/// <summary>UTC timestamp after which this session is considered expired and must be re-uploaded.</summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
/// <summary>
/// ScadaBridge#45: thrown by <c>IBundleImporter.LoadAsync</c> when a bundle fails the
/// signature policy — a signature that does not verify against the trusted key it names,
/// a malformed <c>manifest.sig</c>, or (with <c>Transport:Signing:RequireSignedBundles</c>
/// on) an unsigned bundle or one signed by an untrusted key.
/// <para>
/// Lives in Commons, beside <see cref="BundleLockedException"/>, so the management surface — which
/// references Commons but not Transport — can curate it for the CLI.
/// </para>
/// </summary>
public sealed class BundleSignatureException : Exception
{
/// <summary>Initializes a new <see cref="BundleSignatureException"/>.</summary>
/// <param name="message">Operator-facing reason the bundle was refused.</param>
public BundleSignatureException(string message)
: base(message)
{
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
/// <summary>
/// ScadaBridge#45: the outcome of checking a bundle's optional detached signature
/// (<c>manifest.sig</c>) when it is loaded. A signature that is present, names a trusted
/// key and does NOT verify is never a status — it is a hard
/// <see cref="BundleSignatureException"/>, because it means the manifest (and therefore
/// the content it hashes) was altered after signing.
/// </summary>
public enum BundleSignatureStatus
{
/// <summary>The bundle carries no signature (every bundle produced before signing existed).</summary>
Unsigned = 0,
/// <summary>The signature verified against a key in this cluster's trust store.</summary>
Verified = 1,
/// <summary>
/// The bundle is signed, but by a key this cluster does not trust, so the signature
/// could not be checked. Accepted only while <c>Transport:Signing:RequireSignedBundles</c>
/// is off.
/// </summary>
UntrustedKey = 2,
}
@@ -4543,6 +4543,13 @@ public class ManagementActor : ReceiveActor
// message; no per-bundle strike was burned.
throw new ManagementCommandException(ex.Message);
}
catch (BundleSignatureException ex)
{
// ScadaBridge#45: signature policy refusal (forged/altered signature, or an
// unsigned/untrusted bundle where signing is required). Curated so the CLI
// prints the reason.
throw new ManagementCommandException(ex.Message);
}
// Register row 43: a preview NEVER applies, so nothing else would ever
// release this session — every `transport preview` used to burn one of
@@ -4559,7 +4566,13 @@ public class ManagementActor : ReceiveActor
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
preview.RequiredSiteMappings, preview.RequiredConnectionMappings)
{
// ScadaBridge#45: who signed the bundle, so a scripted preview can gate on it.
SignatureStatus = session.SignatureStatus.ToString(),
SignerKeyId = session.SignerKeyId,
SignerName = session.SignerName,
};
}
finally
{
@@ -4619,6 +4632,13 @@ public class ManagementActor : ReceiveActor
// message; no per-bundle strike was burned.
throw new ManagementCommandException(ex.Message);
}
catch (BundleSignatureException ex)
{
// ScadaBridge#45: signature policy refusal (forged/altered signature, or an
// unsigned/untrusted bundle where signing is required). Curated so the CLI
// prints the reason.
throw new ManagementCommandException(ex.Message);
}
// Register row 43: ApplyAsync releases the session on both its success
// and failure paths, but every abort BEFORE it (blockers, unresolved
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
@@ -27,6 +28,9 @@ public sealed class BundleExporter : IBundleExporter
private readonly IAuditService _auditService;
private readonly ScadaBridgeDbContext _dbContext;
private readonly IOptions<TransportOptions> _options;
// ScadaBridge#45: optional — null (or a signer whose key source yields no key) packs
// an unsigned bundle exactly as before signing existed.
private readonly BundleSigner? _signer;
/// <summary>Initializes the exporter with all pipeline collaborators.</summary>
/// <param name="resolver">Resolves template dependency graphs and transitive entities.</param>
@@ -37,6 +41,7 @@ public sealed class BundleExporter : IBundleExporter
/// <param name="auditService">Records one audit row per export operation.</param>
/// <param name="dbContext">Database context for reading export-time entity state.</param>
/// <param name="options">Transport options (e.g., format version).</param>
/// <param name="signer">Optional (ScadaBridge#45): signs the manifest when a signing key is configured.</param>
public BundleExporter(
DependencyResolver resolver,
EntitySerializer entitySerializer,
@@ -45,7 +50,8 @@ public sealed class BundleExporter : IBundleExporter
BundleSerializer bundleSerializer,
IAuditService auditService,
ScadaBridgeDbContext dbContext,
IOptions<TransportOptions> options)
IOptions<TransportOptions> options,
BundleSigner? signer = null)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
@@ -55,6 +61,7 @@ public sealed class BundleExporter : IBundleExporter
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_options = options ?? throw new ArgumentNullException(nameof(options));
_signer = signer;
}
/// <inheritdoc />
@@ -178,7 +185,21 @@ public sealed class BundleExporter : IBundleExporter
// 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps
// EncryptionMetadata (fresh salt/iv) + ContentHash.
var zipStream = _bundleSerializer.PackContentBytes(contentBytes, templateManifest, passphrase, _encryptor);
// ScadaBridge#45: when a signing key is configured the final manifest bytes are
// signed into a detached manifest.sig entry; the key id is captured for the audit.
string? signingKeyId = null;
var zipStream = _bundleSerializer.PackContentBytes(
contentBytes, templateManifest, passphrase, _encryptor,
_signer is null ? null : manifestBytes =>
{
var sig = _signer.Sign(manifestBytes);
if (sig is not null)
{
signingKeyId = System.Text.Json.JsonDocument.Parse(sig).RootElement
.GetProperty(nameof(BundleSignatureEnvelope.KeyId)).GetString();
}
return sig;
});
// 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes
// (opaque to caller but uniquely identifies this export). Action
@@ -194,6 +215,8 @@ public sealed class BundleExporter : IBundleExporter
{
SourceEnvironment = sourceEnvironment,
Encrypted = passphrase is not null,
Signed = signingKeyId is not null,
SigningKeyId = signingKeyId,
ArtifactCount = resolved.ContentManifest.Count,
Summary = summary,
},
@@ -31,6 +31,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
@@ -103,6 +104,9 @@ public sealed class BundleImporter : IBundleImporter
// what the import wrote. Null on hosts without the central cache-invalidation bus.
private readonly ICentralCacheInvalidationPublisher? _cacheInvalidationPublisher;
private readonly ILogger<BundleImporter>? _logger;
// ScadaBridge#45: signature policy. Never null — when no signer is injected the
// importer uses one with no trusted keys that still honours RequireSignedBundles.
private readonly BundleSigner _signer;
/// <summary>
/// Initializes a new <see cref="BundleImporter"/> with all required dependencies.
@@ -130,6 +134,7 @@ public sealed class BundleImporter : IBundleImporter
/// <param name="watermark">Optional: template-graph version watermark, bumped once per apply attempt so the flatten-session caches and the process-wide staleness memo cannot serve pre-import (or rolled-back) readings. Null on hosts that don't register it.</param>
/// <param name="definitionCacheInvalidator">Optional (Gitea #83): drops this node's external-system / database-connection definition cache after a committed import, so imported definitions take effect immediately rather than after the cache TTL. Null on hosts without the External System Gateway.</param>
/// <param name="cacheInvalidationPublisher">Optional (ScadaBridge#100 item 3): tells the peer central node to drop its caches of what a committed import wrote. Null on hosts without the central cache-invalidation bus.</param>
/// <param name="signer">Optional (ScadaBridge#45): the bundle signature policy. Null falls back to a signer with no trusted keys that still honours <c>Signing:RequireSignedBundles</c>.</param>
public BundleImporter(
BundleSerializer bundleSerializer,
ManifestValidator manifestValidator,
@@ -153,7 +158,8 @@ public sealed class BundleImporter : IBundleImporter
ILogger<BundleImporter>? logger = null,
ITemplateGraphWatermark? watermark = null,
IExternalDefinitionCacheInvalidator? definitionCacheInvalidator = null,
ICentralCacheInvalidationPublisher? cacheInvalidationPublisher = null)
ICentralCacheInvalidationPublisher? cacheInvalidationPublisher = null,
BundleSigner? signer = null)
{
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
@@ -178,6 +184,7 @@ public sealed class BundleImporter : IBundleImporter
_definitionCacheInvalidator = definitionCacheInvalidator;
_cacheInvalidationPublisher = cacheInvalidationPublisher;
_logger = logger;
_signer = signer ?? BundleSigner.CreateWithoutKeys(options);
}
/// <summary>
@@ -203,7 +210,8 @@ public sealed class BundleImporter : IBundleImporter
// buffered inside ReadEnvelopeAsync only, so it becomes unreachable as soon
// as the manifest and the content entry have been extracted, rather than
// staying live alongside the decrypted copy for the rest of this method.
var (manifest, contentBytes) = await ReadEnvelopeAsync(bundleStream, ct).ConfigureAwait(false);
var (manifest, contentBytes, manifestBytes, signatureBytes) =
await ReadEnvelopeAsync(bundleStream, ct).ConfigureAwait(false);
// Validate format version + content-hash + manifest shape. Reject paths
// surface as distinct exceptions so the UI can disambiguate the cause.
@@ -224,6 +232,14 @@ public sealed class BundleImporter : IBundleImporter
throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
}
// ScadaBridge#45: signature policy. Checked after the content-hash check (so a
// verified signature over the manifest transitively covers the content bytes) and
// BEFORE any passphrase work, so an unsigned or forged bundle on a cluster that
// requires signatures is refused without spending an unlock attempt. A bundle with
// no manifest.sig — every bundle produced before signing existed — is Unsigned and
// loads exactly as before unless RequireSignedBundles is on.
var signature = _signer.Verify(manifestBytes, signatureBytes);
// Decrypt when the manifest carries EncryptionMetadata.
//
// T-003: lockout enforcement is server-side and keyed by ContentHash so a
@@ -335,6 +351,9 @@ public sealed class BundleImporter : IBundleImporter
Manifest = manifest,
DecryptedContent = sessionContent,
ExpiresAt = _timeProvider.GetUtcNow() + ttl,
SignatureStatus = signature.Status,
SignerKeyId = signature.KeyId,
SignerName = signature.SignerName,
};
return _sessionStore.Open(session);
}
@@ -347,7 +366,7 @@ public sealed class BundleImporter : IBundleImporter
/// <param name="bundleStream">The uploaded bundle stream (need not be seekable).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The parsed manifest and the content entry's bytes (ciphertext when encrypted).</returns>
private async Task<(BundleManifest Manifest, byte[] ContentBytes)> ReadEnvelopeAsync(
private async Task<(BundleManifest Manifest, byte[] ContentBytes, byte[] ManifestBytes, byte[]? SignatureBytes)> ReadEnvelopeAsync(
Stream bundleStream, CancellationToken ct)
{
// Copy to a seekable buffer — manifest + content readers each open a
@@ -380,10 +399,13 @@ public sealed class BundleImporter : IBundleImporter
ValidateArchiveEnvelope(ms);
BundleManifest manifest;
byte[] manifestBytes;
try
{
ms.Position = 0;
manifest = _bundleSerializer.ReadManifest(ms);
// ScadaBridge#45: keep the exact manifest bytes — a bundle signature covers them.
manifestBytes = _bundleSerializer.ReadManifestBytes(ms);
manifest = _bundleSerializer.DeserializeManifest(manifestBytes);
}
catch (InvalidDataException)
{
@@ -398,7 +420,9 @@ public sealed class BundleImporter : IBundleImporter
ms.Position = 0;
var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest);
return (manifest, contentBytes);
ms.Position = 0;
var signatureBytes = _bundleSerializer.ReadSignatureBytes(ms);
return (manifest, contentBytes, manifestBytes, signatureBytes);
}
/// <summary>
@@ -1965,6 +1989,10 @@ public sealed class BundleImporter : IBundleImporter
BundleImportId = bundleImportId,
session.Manifest.SourceEnvironment,
session.Manifest.ContentHash,
// ScadaBridge#45: who signed what was imported — the non-repudiation record.
SignatureStatus = session.SignatureStatus.ToString(),
session.SignerKeyId,
session.SignerName,
Summary = new
{
summary.Added,
@@ -25,6 +25,13 @@ public sealed class BundleSerializer
private const string ContentJsonEntryName = "content.json";
private const string ContentEncEntryName = "content.enc";
/// <summary>
/// ScadaBridge#45: optional detached signature over the exact <c>manifest.json</c>
/// bytes. Additive — a pre-signing importer opens entries by name, so it never reads
/// this one, and the default <c>MaxBundleEntryCount</c> (4) already admits a third entry.
/// </summary>
public const string SignatureEntryName = "manifest.sig";
// All bundle content serialization goes through BundleJsonOptions.Default —
// see that class for the rationale (WhenWritingNull + unknown-member tolerance).
private static readonly JsonSerializerOptions JsonOptions = BundleJsonOptions.Default;
@@ -71,12 +78,18 @@ public sealed class BundleSerializer
/// <param name="manifest">The bundle manifest describing the content and encryption metadata.</param>
/// <param name="passphrase">Passphrase used for encryption; null produces a plaintext bundle.</param>
/// <param name="encryptor">Encryptor required when <paramref name="passphrase"/> is supplied.</param>
/// <param name="signManifest">
/// ScadaBridge#45: optional signer over the final <c>manifest.json</c> bytes; a non-null
/// result is written as the <see cref="SignatureEntryName"/> entry. <c>null</c> (the default)
/// or a <c>null</c> result packs an unsigned bundle, byte-for-byte as before.
/// </param>
/// <returns>A seeked-to-start <see cref="MemoryStream"/> containing the bundle ZIP archive.</returns>
public Stream PackContentBytes(
byte[] contentBytes,
BundleManifest manifest,
string? passphrase,
BundleSecretEncryptor? encryptor)
BundleSecretEncryptor? encryptor,
Func<byte[], byte[]?>? signManifest = null)
{
ArgumentNullException.ThrowIfNull(contentBytes);
ArgumentNullException.ThrowIfNull(manifest);
@@ -124,11 +137,16 @@ public sealed class BundleSerializer
}
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(finalManifest, JsonOptions);
var signatureBytes = signManifest?.Invoke(manifestBytes);
var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
WriteEntry(archive, ManifestEntryName, manifestBytes);
WriteEntry(archive, payloadEntryName, payload);
if (signatureBytes is not null)
{
WriteEntry(archive, SignatureEntryName, signatureBytes);
}
}
ms.Position = 0;
@@ -154,6 +172,58 @@ public sealed class BundleSerializer
return manifest;
}
/// <summary>
/// ScadaBridge#45: returns the exact bytes of the <c>manifest.json</c> entry — what a
/// bundle signature covers. Deserialise them with <see cref="DeserializeManifest"/>.
/// </summary>
/// <param name="zipStream">The ZIP stream containing the bundle.</param>
/// <returns>The raw manifest bytes.</returns>
public byte[] ReadManifestBytes(Stream zipStream)
{
ArgumentNullException.ThrowIfNull(zipStream);
return ReadEntryBytes(zipStream, ManifestEntryName)
?? throw new InvalidDataException("Bundle is missing manifest.json.");
}
/// <summary>
/// ScadaBridge#45: deserialises manifest bytes obtained from <see cref="ReadManifestBytes"/>.
/// </summary>
/// <param name="manifestBytes">The raw <c>manifest.json</c> bytes.</param>
/// <returns>The deserialized manifest.</returns>
public BundleManifest DeserializeManifest(byte[] manifestBytes)
{
ArgumentNullException.ThrowIfNull(manifestBytes);
return JsonSerializer.Deserialize<BundleManifest>(manifestBytes, JsonOptions)
?? throw new InvalidDataException("manifest.json deserialized to null.");
}
/// <summary>
/// ScadaBridge#45: returns the bytes of the optional <see cref="SignatureEntryName"/> entry,
/// or <c>null</c> for an unsigned bundle (every bundle produced before signing existed).
/// </summary>
/// <param name="zipStream">The ZIP stream containing the bundle.</param>
/// <returns>The signature entry bytes, or <c>null</c>.</returns>
public byte[]? ReadSignatureBytes(Stream zipStream)
{
ArgumentNullException.ThrowIfNull(zipStream);
return ReadEntryBytes(zipStream, SignatureEntryName);
}
private static byte[]? ReadEntryBytes(Stream zipStream, string entryName)
{
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
var entry = archive.GetEntry(entryName);
if (entry is null)
{
return null;
}
using var es = entry.Open();
using var ms = new MemoryStream();
es.CopyTo(ms);
return ms.ToArray();
}
/// <summary>
/// Returns the raw bytes from <c>content.enc</c> if present, otherwise from
/// <c>content.json</c>. The caller decrypts + JSON-deserializes via
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Export;
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;
@@ -41,6 +42,13 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ManifestValidator>();
services.AddSingleton<BundleSerializer>();
services.AddSingleton<EntitySerializer>();
// ScadaBridge#45: bundle signing. The key source and trust store are TryAdd'd so a
// host can plug in its own (HSM / KMS / a shared trust registry) by registering
// before AddTransport(); the defaults read ScadaBridge:Transport:Signing and do
// nothing while it is left at its off-by-default values.
services.TryAddSingleton<IBundleSigningKeySource, ConfiguredBundleSigningKeySource>();
services.TryAddSingleton<IBundleTrustStore, ConfiguredBundleTrustStore>();
services.AddSingleton<BundleSigner>();
services.AddScoped<DependencyResolver>();
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
@@ -0,0 +1,187 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
/// <summary>
/// ScadaBridge#45: the contents of the optional <c>manifest.sig</c> zip entry — a detached
/// ECDSA signature over the exact <c>manifest.json</c> bytes.
/// </summary>
/// <param name="Algorithm">Always <see cref="BundleSigningKeys.Algorithm"/>.</param>
/// <param name="KeyId">The signing key's <c>sha256:</c> fingerprint.</param>
/// <param name="SignatureB64">Base64 of the IEEE P1363 (r‖s) signature.</param>
public sealed record BundleSignatureEnvelope(string Algorithm, string KeyId, string SignatureB64);
/// <summary>ScadaBridge#45: the outcome of a successful signature-policy check.</summary>
/// <param name="Status">Unsigned, verified, or signed by an untrusted key.</param>
/// <param name="KeyId">The signing key's fingerprint; <c>null</c> when unsigned.</param>
/// <param name="SignerName">The trust-store name; set only when verified.</param>
public sealed record BundleSignatureVerification(
BundleSignatureStatus Status, string? KeyId, string? SignerName)
{
/// <summary>The result for a bundle that carries no signature.</summary>
public static BundleSignatureVerification Unsigned { get; } = new(BundleSignatureStatus.Unsigned, null, null);
}
/// <summary>
/// ScadaBridge#45: signs exported bundle manifests and enforces the import-side signature
/// policy. What is signed is the exact serialised <c>manifest.json</c>; because the manifest
/// carries the SHA-256 of the content entry, verifying it also authenticates the content —
/// the importer's existing content-hash check binds the two. Signing never touches the
/// manifest itself or the AES-GCM associated data, so it composes with encryption and a
/// pre-signing importer reads a signed bundle unchanged.
/// </summary>
public sealed class BundleSigner
{
private readonly IBundleSigningKeySource _keySource;
private readonly IBundleTrustStore _trustStore;
private readonly IOptions<TransportOptions> _options;
/// <summary>Initializes the signer.</summary>
/// <param name="keySource">Supplies the export signing key (or none).</param>
/// <param name="trustStore">Resolves a bundle's signing key to a trusted public key.</param>
/// <param name="options">Transport options (<c>Signing:RequireSignedBundles</c>).</param>
public BundleSigner(
IBundleSigningKeySource keySource,
IBundleTrustStore trustStore,
IOptions<TransportOptions> options)
{
_keySource = keySource ?? throw new ArgumentNullException(nameof(keySource));
_trustStore = trustStore ?? throw new ArgumentNullException(nameof(trustStore));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// A signer with no signing key and an empty trust store that still honours
/// <c>Signing:RequireSignedBundles</c> — the importer's fallback when none is injected.
/// </summary>
/// <param name="options">Transport options.</param>
/// <returns>The key-less signer.</returns>
public static BundleSigner CreateWithoutKeys(IOptions<TransportOptions> options) =>
new(NoKeys.Instance, NoKeys.Instance, options);
private sealed class NoKeys : IBundleSigningKeySource, IBundleTrustStore
{
public static readonly NoKeys Instance = new();
public BundleSigningKey? GetSigningKey() => null;
public TrustedBundleKey? Find(string keyId) => null;
}
/// <summary>
/// Signs the manifest bytes with the configured key.
/// </summary>
/// <param name="manifestBytes">The exact bytes that will be written as <c>manifest.json</c>.</param>
/// <returns>The <c>manifest.sig</c> entry bytes, or <c>null</c> when no signing key is configured.</returns>
public byte[]? Sign(byte[] manifestBytes)
{
ArgumentNullException.ThrowIfNull(manifestBytes);
var signingKey = _keySource.GetSigningKey();
if (signingKey is null)
{
return null;
}
using (signingKey.Key)
{
var signature = signingKey.Key.SignData(manifestBytes, HashAlgorithmName.SHA256);
var envelope = new BundleSignatureEnvelope(
BundleSigningKeys.Algorithm, signingKey.KeyId, Convert.ToBase64String(signature));
return JsonSerializer.SerializeToUtf8Bytes(envelope, BundleJsonOptions.Default);
}
}
/// <summary>
/// Applies the import-side signature policy to a loaded bundle.
/// </summary>
/// <param name="manifestBytes">The exact <c>manifest.json</c> bytes read from the bundle.</param>
/// <param name="signatureBytes">The <c>manifest.sig</c> entry bytes, or <c>null</c> when absent.</param>
/// <returns>The verification outcome when the bundle is acceptable.</returns>
/// <exception cref="BundleSignatureException">The bundle fails the policy.</exception>
public BundleSignatureVerification Verify(byte[] manifestBytes, byte[]? signatureBytes)
{
ArgumentNullException.ThrowIfNull(manifestBytes);
var require = _options.Value.Signing.RequireSignedBundles;
if (signatureBytes is null)
{
if (require)
{
throw new BundleSignatureException(
"Bundle is not signed, and this cluster requires signed bundles "
+ "(ScadaBridge:Transport:Signing:RequireSignedBundles).");
}
return BundleSignatureVerification.Unsigned;
}
var envelope = ParseEnvelope(signatureBytes);
var trusted = _trustStore.Find(envelope.KeyId);
if (trusted is null)
{
if (require)
{
throw new BundleSignatureException(
$"Bundle is signed by key {envelope.KeyId}, which is not in this cluster's trusted "
+ "signing keys (ScadaBridge:Transport:Signing:TrustedKeys).");
}
return new BundleSignatureVerification(BundleSignatureStatus.UntrustedKey, envelope.KeyId, null);
}
using (trusted.Key)
{
byte[] signature;
try
{
signature = Convert.FromBase64String(envelope.SignatureB64);
}
catch (FormatException)
{
throw new BundleSignatureException("Bundle signature is malformed (manifest.sig).");
}
if (!trusted.Key.VerifyData(manifestBytes, signature, HashAlgorithmName.SHA256))
{
throw new BundleSignatureException(
$"Bundle signature does not verify against trusted key '{trusted.Name}' — "
+ "the bundle was modified after it was signed.");
}
}
return new BundleSignatureVerification(BundleSignatureStatus.Verified, envelope.KeyId, trusted.Name);
}
private static BundleSignatureEnvelope ParseEnvelope(byte[] signatureBytes)
{
BundleSignatureEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<BundleSignatureEnvelope>(signatureBytes, BundleJsonOptions.Default);
}
catch (JsonException)
{
envelope = null;
}
if (envelope is null
|| string.IsNullOrWhiteSpace(envelope.KeyId)
|| string.IsNullOrWhiteSpace(envelope.SignatureB64))
{
throw new BundleSignatureException("Bundle signature is malformed (manifest.sig).");
}
if (!string.Equals(envelope.Algorithm, BundleSigningKeys.Algorithm, StringComparison.Ordinal))
{
throw new BundleSignatureException(
$"Bundle signature algorithm '{envelope.Algorithm}' is not supported; expected '{BundleSigningKeys.Algorithm}'.");
}
return envelope;
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
/// <summary>
/// ScadaBridge#45: key-material helpers shared by the signer, the default key source, the
/// default trust store and the options validator. Only ECDSA over NIST P-256 is accepted.
/// </summary>
public static class BundleSigningKeys
{
/// <summary>The single signature algorithm the bundle format supports.</summary>
public const string Algorithm = "ECDSA-P256-SHA256";
/// <summary>
/// Loads an ECDSA P-256 private key from a PEM block (<c>PRIVATE KEY</c> or
/// <c>EC PRIVATE KEY</c>) or from the base64 of a PKCS#8 DER blob.
/// </summary>
/// <param name="text">The configured key text.</param>
/// <returns>The loaded key; the caller owns and disposes it.</returns>
/// <exception cref="CryptographicException">The text is not a P-256 private key.</exception>
public static ECDsa LoadPrivateKey(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var key = ECDsa.Create();
try
{
if (text.Contains("-----BEGIN", StringComparison.Ordinal))
{
key.ImportFromPem(text);
}
else
{
key.ImportPkcs8PrivateKey(DecodeBase64(text), out _);
}
RequireP256(key);
// A public-only PEM imports without error; signing would then throw much later.
_ = key.ExportParameters(includePrivateParameters: true);
return key;
}
catch
{
key.Dispose();
throw;
}
}
/// <summary>
/// Loads an ECDSA P-256 public key from a <c>PUBLIC KEY</c> PEM block or from the base64
/// of a SubjectPublicKeyInfo DER blob.
/// </summary>
/// <param name="text">The configured key text.</param>
/// <returns>The loaded key; the caller owns and disposes it.</returns>
/// <exception cref="CryptographicException">The text is not a P-256 public key.</exception>
public static ECDsa LoadPublicKey(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var key = ECDsa.Create();
try
{
if (text.Contains("-----BEGIN", StringComparison.Ordinal))
{
key.ImportFromPem(text);
}
else
{
key.ImportSubjectPublicKeyInfo(DecodeBase64(text), out _);
}
RequireP256(key);
return key;
}
catch
{
key.Dispose();
throw;
}
}
/// <summary>
/// The key id a bundle names its signer by: <c>sha256:</c> + lowercase hex of the SHA-256
/// of the key's SubjectPublicKeyInfo DER. Derived, never configured, so a trust-store entry
/// cannot claim an id that does not belong to its key.
/// </summary>
/// <param name="key">A public or private P-256 key.</param>
/// <returns>The key fingerprint.</returns>
public static string ComputeKeyId(ECDsa key)
{
ArgumentNullException.ThrowIfNull(key);
var spki = key.ExportSubjectPublicKeyInfo();
return "sha256:" + Convert.ToHexString(SHA256.HashData(spki)).ToLowerInvariant();
}
private static void RequireP256(ECDsa key)
{
var curve = key.ExportParameters(includePrivateParameters: false).Curve;
if (key.KeySize != 256 || !curve.IsNamed
|| !(string.Equals(curve.Oid.Value, ECCurve.NamedCurves.nistP256.Oid.Value, StringComparison.Ordinal)
|| string.Equals(curve.Oid.FriendlyName, ECCurve.NamedCurves.nistP256.Oid.FriendlyName, StringComparison.OrdinalIgnoreCase)))
{
throw new CryptographicException("Bundle signing keys must be ECDSA over NIST P-256.");
}
}
private static byte[] DecodeBase64(string text)
{
try
{
return Convert.FromBase64String(text.Trim());
}
catch (FormatException ex)
{
throw new CryptographicException("Key is neither a PEM block nor valid base64.", ex);
}
}
}
@@ -0,0 +1,77 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
/// <summary>
/// ScadaBridge#45: default <see cref="IBundleSigningKeySource"/> — the key configured at
/// <c>ScadaBridge:Transport:Signing:PrivateKey</c>, used only while
/// <c>Signing:Enabled</c> is <c>true</c>. Startup validation has already proven the key
/// parses, so a failure here would be a configuration change after boot.
/// </summary>
public sealed class ConfiguredBundleSigningKeySource : IBundleSigningKeySource
{
private readonly IOptions<TransportOptions> _options;
/// <summary>Initializes the key source over the Transport options.</summary>
/// <param name="options">Transport options.</param>
public ConfiguredBundleSigningKeySource(IOptions<TransportOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public BundleSigningKey? GetSigningKey()
{
var signing = _options.Value.Signing;
if (!signing.Enabled || string.IsNullOrWhiteSpace(signing.PrivateKey))
{
return null;
}
var key = BundleSigningKeys.LoadPrivateKey(signing.PrivateKey);
return new BundleSigningKey(key, BundleSigningKeys.ComputeKeyId(key));
}
}
/// <summary>
/// ScadaBridge#45: default <see cref="IBundleTrustStore"/> — the public keys configured at
/// <c>ScadaBridge:Transport:Signing:TrustedKeys</c>, matched by their computed fingerprint.
/// </summary>
public sealed class ConfiguredBundleTrustStore : IBundleTrustStore
{
private readonly IOptions<TransportOptions> _options;
/// <summary>Initializes the trust store over the Transport options.</summary>
/// <param name="options">Transport options.</param>
public ConfiguredBundleTrustStore(IOptions<TransportOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public TrustedBundleKey? Find(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return null;
}
foreach (var entry in _options.Value.Signing.TrustedKeys)
{
if (string.IsNullOrWhiteSpace(entry.PublicKey))
{
continue;
}
var key = BundleSigningKeys.LoadPublicKey(entry.PublicKey);
if (string.Equals(BundleSigningKeys.ComputeKeyId(key), keyId, StringComparison.Ordinal))
{
return new TrustedBundleKey(entry.Name, key);
}
key.Dispose();
}
return null;
}
}
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
/// <summary>
/// ScadaBridge#45: supplies the private key that signs exported bundles. Pluggable — the
/// default <see cref="ConfiguredBundleSigningKeySource"/> reads
/// <c>ScadaBridge:Transport:Signing:PrivateKey</c> (a <c>${secret:}</c> reference in
/// production); a host can register its own implementation (an HSM, a KMS) before
/// <c>AddTransport()</c>, which only <c>TryAdd</c>s the default.
/// </summary>
public interface IBundleSigningKeySource
{
/// <summary>
/// Returns the signing key, or <c>null</c> when exports should not be signed. The caller
/// disposes the returned key's <see cref="BundleSigningKey.Key"/>.
/// </summary>
/// <returns>The key and its fingerprint, or <c>null</c>.</returns>
BundleSigningKey? GetSigningKey();
}
/// <summary>ScadaBridge#45: a private signing key plus its <see cref="BundleSigningKeys.ComputeKeyId"/> fingerprint.</summary>
/// <param name="Key">The ECDSA P-256 private key.</param>
/// <param name="KeyId">The key's <c>sha256:</c> fingerprint.</param>
public sealed record BundleSigningKey(ECDsa Key, string KeyId);
/// <summary>
/// ScadaBridge#45: resolves a bundle's signing-key fingerprint to a trusted public key.
/// Pluggable for the same reason as <see cref="IBundleSigningKeySource"/>; the default
/// <see cref="ConfiguredBundleTrustStore"/> reads <c>ScadaBridge:Transport:Signing:TrustedKeys</c>.
/// </summary>
public interface IBundleTrustStore
{
/// <summary>Looks up a trusted key by fingerprint.</summary>
/// <param name="keyId">The <c>sha256:</c> fingerprint named by the bundle.</param>
/// <returns>The trusted key, or <c>null</c> when the fingerprint is not trusted. The caller disposes it.</returns>
TrustedBundleKey? Find(string keyId);
}
/// <summary>ScadaBridge#45: a trusted public key and its operator-facing name.</summary>
/// <param name="Name">The trust-store name, shown on a verified import.</param>
/// <param name="Key">The ECDSA P-256 public key.</param>
public sealed record TrustedBundleKey(string Name, ECDsa Key);
@@ -61,4 +61,61 @@ public sealed class TransportOptions
/// the default placeholder is fine for single-cluster deployments.
/// </summary>
public string SourceEnvironment { get; set; } = "scadabridge";
/// <summary>
/// ScadaBridge#45: optional detached bundle signing. Off by default — with the defaults
/// a cluster exports unsigned bundles and imports signed and unsigned bundles alike,
/// exactly as before signing existed.
/// </summary>
public TransportSigningOptions Signing { get; set; } = new();
}
/// <summary>
/// ScadaBridge#45: bound from <c>ScadaBridge:Transport:Signing</c>. Signing is a
/// non-repudiation layer on top of (not instead of) the passphrase encryption: the exporting
/// cluster signs the exact <c>manifest.json</c> bytes with an ECDSA P-256 private key, and
/// since the manifest carries the SHA-256 of the content entry, the signature covers the
/// whole bundle. The signature rides in an optional <c>manifest.sig</c> zip entry, so the
/// bundle format version does not change and older importers ignore it.
/// </summary>
public sealed class TransportSigningOptions
{
/// <summary>
/// When <c>true</c>, every export is signed with the key from the registered
/// <c>IBundleSigningKeySource</c> (by default <see cref="PrivateKey"/>). Default <c>false</c>.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// The ECDSA P-256 private key used to sign exports — a PKCS#8 / SEC1 PEM block, or the
/// base64 of the PKCS#8 DER. Supply it as a <c>${secret:NAME}</c> reference so it lives
/// in the encrypted secret store rather than in <c>appsettings</c>. Only read when
/// <see cref="Enabled"/> is <c>true</c> and the default key source is in use.
/// </summary>
public string? PrivateKey { get; set; }
/// <summary>
/// When <c>true</c>, the importer refuses a bundle that is unsigned or signed by a key
/// not in <see cref="TrustedKeys"/>. Default <c>false</c> — such bundles load and the
/// signature status is shown to the operator. A signature that is present, names a
/// trusted key and fails to verify is refused regardless of this flag.
/// </summary>
public bool RequireSignedBundles { get; set; }
/// <summary>
/// Public keys this cluster trusts to sign bundles. Each entry's key id is the
/// <c>sha256:</c> fingerprint of its SubjectPublicKeyInfo, so no id needs to be
/// configured — a bundle names its signing key by that fingerprint.
/// </summary>
public List<TrustedBundleSigningKey> TrustedKeys { get; set; } = new();
}
/// <summary>ScadaBridge#45: one entry of <see cref="TransportSigningOptions.TrustedKeys"/>.</summary>
public sealed class TrustedBundleSigningKey
{
/// <summary>Operator-facing name for the key (e.g. the exporting environment), shown on a verified import.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>The ECDSA P-256 public key — a <c>PUBLIC KEY</c> PEM block, or the base64 of the SubjectPublicKeyInfo DER.</summary>
public string PublicKey { get; set; } = string.Empty;
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport;
@@ -60,8 +61,62 @@ public sealed class TransportOptionsValidator : OptionsValidatorBase<TransportOp
$"ScadaBridge:Transport:Pbkdf2Iterations must be at least {MinPbkdf2Iterations:N0} " +
$"(was {options.Pbkdf2Iterations}); a lower value silently weakens bundle key derivation.");
ValidateSigning(builder, options.Signing);
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SourceEnvironment),
"ScadaBridge:Transport:SourceEnvironment must be a non-empty name; it is stamped into " +
"BundleManifest.SourceEnvironment and the export filename.");
}
/// <summary>
/// ScadaBridge#45: a signing key or trusted key that does not parse must fail at boot,
/// not on the first export/import — a typo would otherwise silently ship unsigned
/// bundles or reject every signed one.
/// </summary>
private static void ValidateSigning(ValidationBuilder builder, TransportSigningOptions signing)
{
if (signing is null)
{
builder.RequireThat(false, "ScadaBridge:Transport:Signing must not be null.");
return;
}
if (signing.Enabled)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(signing.PrivateKey),
"ScadaBridge:Transport:Signing:PrivateKey is required when Signing:Enabled is true; " +
"supply the ECDSA P-256 private key (PEM or base64 PKCS#8), ideally as a ${secret:} reference.");
if (!string.IsNullOrWhiteSpace(signing.PrivateKey))
{
builder.RequireThat(TryLoad(() => BundleSigningKeys.LoadPrivateKey(signing.PrivateKey)),
"ScadaBridge:Transport:Signing:PrivateKey is not an ECDSA P-256 private key " +
"(PEM or base64 PKCS#8).");
}
}
for (var i = 0; i < signing.TrustedKeys.Count; i++)
{
var entry = signing.TrustedKeys[i];
builder.RequireThat(!string.IsNullOrWhiteSpace(entry.Name),
$"ScadaBridge:Transport:Signing:TrustedKeys:{i}:Name must be a non-empty name.");
builder.RequireThat(!string.IsNullOrWhiteSpace(entry.PublicKey)
&& TryLoad(() => BundleSigningKeys.LoadPublicKey(entry.PublicKey)),
$"ScadaBridge:Transport:Signing:TrustedKeys:{i}:PublicKey is not an ECDSA P-256 public key " +
"(PEM or base64 SubjectPublicKeyInfo).");
}
}
private static bool TryLoad(Func<System.Security.Cryptography.ECDsa> load)
{
try
{
using var key = load();
return true;
}
catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException
or ArgumentException)
{
return false;
}
}
}