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,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
|
||||
|
||||
Reference in New Issue
Block a user