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:
@@ -25,6 +25,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
||||
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. A template travels with **all** of its child collections — `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, and `TemplateNativeAlarmSource` (including `IsInherited` placeholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Field completeness is verified by a reflection round-trip guard over every writable property (Add + Overwrite), and includes the fields earlier revisions dropped: the derived-lock flag `LockedInDerived` on attributes/alarms/scripts, script cadence/timeout (`MinTimeBetweenRuns` / `ExecutionTimeoutSeconds`), and — for instances — the real `AreaName` (the exporter previously emitted `null`, half-shipping the Area-by-name feature). Carrying the native alarm sources is what keeps an imported `InstanceNativeAlarmSourceOverride` from dangling against a source the target template would otherwise lack.
|
||||
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
|
||||
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||
- **Optionally sign** exported bundles (ECDSA P-256 detached signature in `manifest.sig`) and enforce an import-side signature policy against a trust store — off by default (ScadaBridge#45; see "Bundle Signing").
|
||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
|
||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip executable C# surface a bundle carries — template / shared / ApiMethod script bodies, template script **and** alarm **Expression-trigger** bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`); bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime — all these surfaces hard-block uniformly, since the trust gate's semantic verdict is authoritative with no false-positive channel, and the advisory-vs-hard severity split is a property of the name-resolution heuristic only), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
||||
@@ -42,10 +43,11 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
||||
bundle.scadabundle
|
||||
├── manifest.json # required, NOT encrypted
|
||||
├── content.json # plaintext artifact data (when no passphrase)
|
||||
└── content.enc # encrypted artifact data (when passphrase set)
|
||||
├── content.enc # encrypted artifact data (when passphrase set)
|
||||
└── manifest.sig # OPTIONAL detached signature over manifest.json (ScadaBridge#45)
|
||||
```
|
||||
|
||||
Exactly one of `content.json` or `content.enc` is present. Script bodies (template
|
||||
Exactly one of `content.json` or `content.enc` is present. `manifest.sig` is present only when the exporting cluster has signing enabled; a pre-#45 importer opens entries by name and never reads it, and three entries sit inside the default `MaxBundleEntryCount` (4). Script bodies (template
|
||||
scripts, shared scripts, ApiMethod scripts) travel **inline** inside the `content`
|
||||
blob, not as separate `scripts/` files — a well-formed bundle has exactly two zip
|
||||
entries (`manifest.json` + one content blob). A separate `scripts/` directory is
|
||||
@@ -325,6 +327,7 @@ As of M8 the import result enumerates affected instances for real (closing the p
|
||||
| Upload | Not a zip / missing `manifest.json` | Step 1 error: "Not a valid ScadaBridge bundle" |
|
||||
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaBridge v{x}; upgrade this cluster" |
|
||||
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
|
||||
| Upload | Signature fails the policy (ScadaBridge#45) — altered/forged signature, malformed `manifest.sig`, or unsigned/untrusted with `RequireSignedBundles` on | Step 1 error: "Bundle signature rejected: …" (checked before the passphrase; no unlock attempt spent) |
|
||||
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
|
||||
| Map | Source site/connection neither auto-matched nor mapped/created | Listed as a blocker row in the Map step (UI) / Diff step; cannot Apply until mapped or set to Create-new. CLI aborts before apply unless `--create-missing-sites` / `--create-missing-connections` is given |
|
||||
| Map | `CreateNew` selected but the bundle does not carry that site/connection's config | Blocker — Create-new requires the full config to be present in the bundle payload |
|
||||
@@ -352,6 +355,40 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a
|
||||
- **Defense in depth on authorization:** `RequireDesign` (export) and `RequireAdmin` (import) are enforced both on the Razor page and inside `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints. The UI is not the only gate.
|
||||
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (import).
|
||||
|
||||
### Bundle Signing (ScadaBridge#45)
|
||||
|
||||
Signing adds **non-repudiation** on top of the passphrase encryption — the passphrase proves only that someone who knew it built the bundle; a signature proves *which cluster* did. It is **off by default** and purely additive: `bundleFormatVersion` stays `1`, `manifest.json` gains no field, and the AES-GCM associated data (`BundleManifestAad`) is untouched.
|
||||
|
||||
- **What is signed.** The exporter serialises the final `manifest.json`, signs those exact bytes with ECDSA P-256 / SHA-256 (IEEE P1363 signature), and writes `manifest.sig` = `{"Algorithm":"ECDSA-P256-SHA256","KeyId":"sha256:<hex>","SignatureB64":"..."}`. The manifest carries `contentHash`, and the importer already rejects a content entry that does not match it, so a verified signature authenticates the whole bundle — including the plaintext `sourceEnvironment` the confirm step asks the operator to retype, which for an **unencrypted** bundle nothing else protects. Signing raw bytes (detached) avoids any JSON canonicalisation, so a future additive manifest field cannot break verification on an older cluster.
|
||||
- **Key id.** `sha256:` + hex SHA-256 of the key's SubjectPublicKeyInfo DER — derived from the key, never configured, so a trust-store entry cannot claim another key's id.
|
||||
- **Import policy** (`BundleImporter.LoadAsync`, after the content-hash check and **before** any passphrase work, so a refusal spends no unlock attempt):
|
||||
|
||||
| Bundle | `RequireSignedBundles=false` (default) | `RequireSignedBundles=true` |
|
||||
|---|---|---|
|
||||
| No `manifest.sig` (every pre-#45 bundle) | loads, status **Unsigned** | refused |
|
||||
| Signed by a key not in `TrustedKeys` | loads, status **UntrustedKey** (fingerprint shown) | refused |
|
||||
| Signed by a trusted key, verifies | loads, status **Verified** (+ trust-store name) | loads, **Verified** |
|
||||
| Signed by a trusted key, does not verify / malformed `manifest.sig` / unknown algorithm | **refused** | **refused** |
|
||||
|
||||
A refusal is `BundleSignatureException` (Commons, curated on the Central UI upload step and on the CLI `bundle preview` / `bundle import`). The status rides on `BundleSession` (`SignatureStatus` / `SignerKeyId` / `SignerName`), is shown in the import wizard's manifest summary, is returned on `PreviewBundleResult`, and is recorded on the `BundleImported` audit row; `BundleExported` records `Signed` + `SigningKeyId`.
|
||||
- **Configuration** (`ScadaBridge:Transport:Signing`, startup-validated — a key that does not parse fails boot):
|
||||
|
||||
```json
|
||||
"Signing": {
|
||||
"Enabled": false, // sign exports
|
||||
"PrivateKey": "${secret:SB-BUNDLE-SIGNING-KEY}", // P-256, PKCS#8/SEC1 PEM or base64 PKCS#8 DER
|
||||
"RequireSignedBundles": false, // refuse unsigned / untrusted bundles on import
|
||||
"TrustedKeys": [ { "Name": "dev-cluster-a", "PublicKey": "-----BEGIN PUBLIC KEY-----..." } ]
|
||||
}
|
||||
```
|
||||
|
||||
Generate a key pair with `openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out bundle-signing.pem` and `openssl ec -in bundle-signing.pem -pubout`. The private key belongs in the encrypted secret store, referenced as `${secret:}` (expanded pre-host like every other config secret); both central nodes of a cluster share it, since either may run the export.
|
||||
- **Pluggable key material.** `IBundleSigningKeySource` (export key) and `IBundleTrustStore` (fingerprint → trusted public key) are `TryAdd`-registered; a host can register its own (HSM/KMS signing, a shared trust registry) before `AddTransport()`.
|
||||
- **Open decisions** (not settled by the original design, recorded here rather than guessed in code): (1) whether each environment gets its own signing key or one key per organisation; (2) how trust is distributed and rotated beyond hand-edited `TrustedKeys` (a trust bundle in the config DB editable from the Admin UI? key expiry/revocation?); (3) whether `RequireSignedBundles` should become the default at the production boundary once every exporting cluster signs; (4) whether the default key source should read the Secrets store directly (by secret name) instead of through `${secret:}` config expansion.
|
||||
- **Compatibility proof.** `Transport.IntegrationTests/Fixtures/pre-signing-*.scadabundle` were exported by the pre-#45 code and are imported end to end by `LegacyBundleCompatibilityTests` (plaintext and encrypted, signing off and on); `SignedBundleLoadTests.Signed_bundle_is_readable_by_pre_signing_read_path` shows the reverse direction.
|
||||
|
||||
Cluster-to-cluster pull and differential bundles (the other two items deferred with signing) are **not implemented**; their design questions are recorded in `docs/plans/2026-11-09-transport-pull-and-differential-addendum.md`.
|
||||
|
||||
## Configuration Audit Trail
|
||||
|
||||
Import flows through the same audited repository methods the UI and CLI use, so every artifact mutated by `ApplyAsync` emits the existing per-entity `AuditLogEntry` row:
|
||||
|
||||
@@ -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 — 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,6 +935,74 @@ public class TransportImportPageTests : BunitContext
|
||||
// widening the surface of the production class with test-only accessors.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#45: a signature-policy refusal is shown as its own reason and is NOT
|
||||
/// counted as a wrong passphrase (the importer checks the signature before decrypting).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Signature_rejection_shows_reason_without_counting_a_failed_unlock()
|
||||
{
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Throws(new BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
|
||||
SetField(cut.Instance, "_passphrase", "any");
|
||||
});
|
||||
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync"));
|
||||
|
||||
Assert.Equal(0, GetField<int>(cut.Instance, "_failedUnlockAttempts"));
|
||||
var error = GetField<string?>(cut.Instance, "_errorMessage");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("Bundle signature rejected", error);
|
||||
Assert.Contains("requires signed bundles", error);
|
||||
}
|
||||
|
||||
/// <summary>ScadaBridge#45: the upload step's manifest summary shows the bundle's signature status.</summary>
|
||||
/// <param name="status">The session's signature status.</param>
|
||||
/// <param name="expected">Text the Signature row must contain.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(BundleSignatureStatus.Verified, "Verified")]
|
||||
[InlineData(BundleSignatureStatus.UntrustedKey, "untrusted key")]
|
||||
[InlineData(BundleSignatureStatus.Unsigned, "Unsigned")]
|
||||
public async Task Manifest_summary_shows_signature_status(BundleSignatureStatus status, string expected)
|
||||
{
|
||||
var cut = Render<TransportImportPage>();
|
||||
var session = BuildEncryptedSession();
|
||||
session = new BundleSession
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
Manifest = session.Manifest,
|
||||
DecryptedContent = session.DecryptedContent,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
SignatureStatus = status,
|
||||
SignerKeyId = status == BundleSignatureStatus.Unsigned ? null : "sha256:feed",
|
||||
SignerName = status == BundleSignatureStatus.Verified ? "dev-cluster" : null,
|
||||
};
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01 });
|
||||
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Upload);
|
||||
SetField(cut.Instance, "_session", session);
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
var row = cut.Find("[data-testid=manifest-signature]");
|
||||
Assert.Contains(expected, row.TextContent);
|
||||
if (status == BundleSignatureStatus.Verified)
|
||||
{
|
||||
Assert.Contains("dev-cluster", row.TextContent);
|
||||
}
|
||||
if (status == BundleSignatureStatus.UntrustedKey)
|
||||
{
|
||||
Assert.Contains("sha256:feed", row.TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetField(object obj, string name, object? value)
|
||||
{
|
||||
var field = obj.GetType().GetField(
|
||||
|
||||
@@ -4669,6 +4669,63 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain("An internal error occurred", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>ScadaBridge#45: a signature-policy refusal reaches the CLI as the curated reason, on preview and import.</summary>
|
||||
[Fact]
|
||||
public void BundleCommands_SignatureRejected_SurfaceTheCuratedReason()
|
||||
{
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns<Commons.Types.Transport.BundleSession>(_ =>
|
||||
throw new Commons.Types.Transport.BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
|
||||
var preview = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
|
||||
var import = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
|
||||
foreach (var response in new[] { preview, import })
|
||||
{
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("requires signed bundles", response.Error);
|
||||
Assert.DoesNotContain("An internal error occurred", response.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ScadaBridge#45: the preview result carries the bundle's signature status and signer.</summary>
|
||||
[Fact]
|
||||
public void PreviewBundleCommand_ReportsSignatureStatus()
|
||||
{
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
SignatureStatus = Commons.Types.Transport.BundleSignatureStatus.Verified,
|
||||
SignerKeyId = "sha256:abc",
|
||||
SignerName = "dev-cluster",
|
||||
});
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId, Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new PreviewBundleCommand(Convert.ToBase64String(new byte[] { 1 }), null), "Administrator"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(response.JsonData);
|
||||
var root = doc.RootElement;
|
||||
string Prop(string name) => root.EnumerateObject()
|
||||
.Single(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)).Value.GetString()!;
|
||||
Assert.Equal("Verified", Prop("SignatureStatus"));
|
||||
Assert.Equal("sha256:abc", Prop("SignerKeyId"));
|
||||
Assert.Equal("dev-cluster", Prop("SignerName"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native alarm source CRUD
|
||||
// ========================================================================
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+207
@@ -0,0 +1,207 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#45 — bundle-format compatibility. The two bundles under <c>Fixtures/</c> were
|
||||
/// exported by the Transport code from BEFORE bundle signing existed (<c>origin/main</c>
|
||||
/// <c>3ff463b6</c>, real <c>AddTransport()</c> pipeline over in-memory EF, the
|
||||
/// <see cref="RoundTripTests"/> seed: templates <c>BaseDevice</c> / <c>Pump</c> (composes
|
||||
/// <c>BaseDevice</c> as <c>base</c>) / <c>Standalone</c>, shared script <c>HelperFn</c>,
|
||||
/// external system <c>ErpSystem</c>, notification list <c>OnCall</c>; source environment
|
||||
/// <c>legacy-env</c>). <c>pre-signing-plaintext</c> is unencrypted; <c>pre-signing-encrypted</c>
|
||||
/// uses the passphrase <see cref="LegacyPassphrase"/>.
|
||||
/// <para>
|
||||
/// <b>Never regenerate these files</b> — their value is that old code wrote them. When the
|
||||
/// format changes again, add a new fixture beside them.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LegacyBundleCompatibilityTests
|
||||
{
|
||||
private const string LegacyPassphrase = "legacy-fixture-passphrase";
|
||||
|
||||
private static ServiceProvider BuildProvider(IDictionary<string, string?> settings)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
|
||||
var dbName = $"LegacyBundleCompat_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Signing configured (key + trusted keys) but not required — the realistic rollout state.</summary>
|
||||
private static Dictionary<string, string?> SigningConfigured()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
return new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Transport:Signing:Enabled"] = "true",
|
||||
["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(),
|
||||
["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self",
|
||||
["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(),
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] Fixture(string name) =>
|
||||
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
|
||||
|
||||
public static TheoryData<string, string?, bool> Cases() => new()
|
||||
{
|
||||
{ "pre-signing-plaintext.scadabundle", null, false },
|
||||
{ "pre-signing-encrypted.scadabundle", LegacyPassphrase, false },
|
||||
{ "pre-signing-plaintext.scadabundle", null, true },
|
||||
{ "pre-signing-encrypted.scadabundle", LegacyPassphrase, true },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A bundle produced before signing existed loads, previews and applies in full — with
|
||||
/// signing left at its defaults and with signing configured (not required).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public async Task Pre_signing_bundle_still_imports(string fixture, string? passphrase, bool signingConfigured)
|
||||
{
|
||||
await using var provider = BuildProvider(
|
||||
signingConfigured ? SigningConfigured() : new Dictionary<string, string?>());
|
||||
|
||||
BundleSession session;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
session = await importer.LoadAsync(new MemoryStream(Fixture(fixture)), passphrase);
|
||||
}
|
||||
Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus);
|
||||
Assert.Equal("legacy-env", session.Manifest.SourceEnvironment);
|
||||
|
||||
ImportResult result;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var preview = await importer.PreviewAsync(session.SessionId);
|
||||
Assert.Equal(6, preview.Items.Count);
|
||||
Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind));
|
||||
var resolutions = preview.Items
|
||||
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
|
||||
.ToList();
|
||||
result = await importer.ApplyAsync(session.SessionId, resolutions, user: "bob");
|
||||
}
|
||||
Assert.Equal(6, result.Added);
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var baseDevice = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "BaseDevice");
|
||||
Assert.Equal("init", Assert.Single(baseDevice.Scripts).Name);
|
||||
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
|
||||
Assert.Equal(baseDevice.Id, Assert.Single(pump.Compositions).ComposedTemplateId);
|
||||
Assert.Equal("return 1;", (await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn")).Code);
|
||||
Assert.True(await ctx.ExternalSystemDefinitions.AnyAsync(e => e.Name == "ErpSystem"));
|
||||
Assert.True(await ctx.NotificationLists.AnyAsync(n => n.Name == "OnCall"));
|
||||
var imported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImported");
|
||||
Assert.Contains("\"SignatureStatus\":\"Unsigned\"", imported.AfterStateJson!, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <c>RequireSignedBundles</c> on, the same pre-signing bundle is refused — the flag is
|
||||
/// the one deliberate way to stop accepting old bundles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pre_signing_bundle_is_refused_when_signing_required()
|
||||
{
|
||||
var settings = SigningConfigured();
|
||||
settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true";
|
||||
await using var provider = BuildProvider(settings);
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
|
||||
await Assert.ThrowsAsync<BundleSignatureException>(() => importer.LoadAsync(
|
||||
new MemoryStream(Fixture("pre-signing-encrypted.scadabundle")), LegacyPassphrase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end through DI: with signing enabled the exporter writes a signed, encrypted bundle
|
||||
/// (and audits the key), and an importer that trusts the key loads it as Verified and
|
||||
/// records the signer on the BundleImported row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Signed_export_imports_as_Verified_through_DI()
|
||||
{
|
||||
var settings = SigningConfigured();
|
||||
settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true";
|
||||
await using var provider = BuildProvider(settings);
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.SharedScripts.Add(new SharedScript("Signed", "return 2;"));
|
||||
ctx.Templates.Add(new Template("SignedTemplate"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
byte[] bundle;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||
SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: true);
|
||||
var stream = await scope.ServiceProvider.GetRequiredService<IBundleExporter>()
|
||||
.ExportAsync(selection, "alice", "signing-env", "pw-for-signed-bundle");
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
bundle = ms.ToArray();
|
||||
|
||||
var exported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleExported");
|
||||
Assert.Contains("\"Signed\":true", exported.AfterStateJson!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var keyId = scope.ServiceProvider.GetRequiredService<IBundleSigningKeySource>().GetSigningKey()!.KeyId;
|
||||
var session = await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(new MemoryStream(bundle), "pw-for-signed-bundle");
|
||||
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
|
||||
Assert.Equal("self", session.SignerName);
|
||||
Assert.Equal(keyId, session.SignerKeyId);
|
||||
scope.ServiceProvider.GetRequiredService<IBundleImporter>().Abandon(session.SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -30,6 +30,12 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- ScadaBridge#45: bundles exported by the pre-signing code (origin/main 3ff463b6),
|
||||
checked in byte-for-byte to prove later format changes still import them. -->
|
||||
<None Include="Fixtures/*.scadabundle" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user