feat(transport): optional detached bundle signing with a pluggable key source (ScadaBridge#45)

Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.

- The bundle format does not change: bundleFormatVersion stays 1,
  manifest.json and the AES-GCM associated data are untouched, and older
  importers open entries by name and ignore manifest.sig. Three entries fit
  within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
  work. A signature that fails to verify against a trusted key is always
  refused. RequireSignedBundles additionally refuses unsigned bundles and
  bundles signed by an untrusted key. A refusal is BundleSignatureException
  (Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
  shown in the import wizard. They are also returned on PreviewBundleResult
  and recorded on the BundleImported audit row. BundleExported records
  Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
  are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
  ${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
  fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
  pre-signing-encrypted were exported by origin/main 3ff463b6, before this
  change, and are imported end to end with signing off and with signing
  configured.

The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:31:55 -05:00
parent 1ae4c7a4cb
commit 205a06a0f1
26 changed files with 1528 additions and 11 deletions
@@ -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