audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -93,7 +94,7 @@ public sealed class ExcititorVexImportTarget : IVexImportTarget
|
||||
Content: contentBytes,
|
||||
Metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("importSource", "airgap-snapshot")
|
||||
.Add("snapshotAt", data.SnapshotAt.ToString("O")));
|
||||
.Add("snapshotAt", data.SnapshotAt.ToString("O", CultureInfo.InvariantCulture)));
|
||||
|
||||
await _sink.StoreAsync(document, cancellationToken);
|
||||
created++;
|
||||
|
||||
@@ -59,6 +59,9 @@ public static class AirGapSyncServiceCollectionExtensions
|
||||
// Bundle exporter
|
||||
services.TryAddSingleton<IAirGapBundleExporter, AirGapBundleExporter>();
|
||||
|
||||
// Bundle DSSE signer (OMP-010)
|
||||
services.TryAddSingleton<IAirGapBundleDsseSigner, AirGapBundleDsseSigner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// <copyright file="AirGapBundleDsseSigner.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options for air-gap bundle DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class AirGapBundleDsseOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "AirGap:BundleSigning";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signing mode: "hmac" for HMAC-SHA256, "none" to disable.
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HMAC secret key as Base64.
|
||||
/// Required when Mode is "hmac".
|
||||
/// </summary>
|
||||
public string? SecretBase64 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key identifier for the signature.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = "airgap-bundle-signer";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the payload type for DSSE envelope.
|
||||
/// </summary>
|
||||
public string PayloadType { get; set; } = "application/vnd.stellaops.airgap.bundle+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bundle signature operation.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The key ID used for signing.</param>
|
||||
/// <param name="Signature">The signature bytes.</param>
|
||||
/// <param name="SignatureBase64">The signature as Base64 string.</param>
|
||||
public sealed record AirGapBundleSignatureResult(
|
||||
string KeyId,
|
||||
byte[] Signature,
|
||||
string SignatureBase64);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for air-gap bundle DSSE signing.
|
||||
/// </summary>
|
||||
public interface IAirGapBundleDsseSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs an air-gap bundle manifest and returns the signature result.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to sign.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Signature result with key ID and signature.</returns>
|
||||
Task<AirGapBundleSignatureResult?> SignAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an air-gap bundle signature.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid or signing is disabled; false if invalid.</returns>
|
||||
Task<AirGapBundleVerificationResult> VerifyAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether signing is enabled.
|
||||
/// </summary>
|
||||
bool IsEnabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle signature verification.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">Whether the signature is valid.</param>
|
||||
/// <param name="Reason">The reason for the result.</param>
|
||||
public sealed record AirGapBundleVerificationResult(bool IsValid, string Reason)
|
||||
{
|
||||
/// <summary>
|
||||
/// Verification succeeded.
|
||||
/// </summary>
|
||||
public static AirGapBundleVerificationResult Valid { get; } = new(true, "Signature verified");
|
||||
|
||||
/// <summary>
|
||||
/// Signing is disabled, so verification is skipped.
|
||||
/// </summary>
|
||||
public static AirGapBundleVerificationResult SigningDisabled { get; } = new(true, "Signing disabled");
|
||||
|
||||
/// <summary>
|
||||
/// Bundle has no signature but signing is enabled.
|
||||
/// </summary>
|
||||
public static AirGapBundleVerificationResult MissingSignature { get; } = new(false, "Bundle is not signed");
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification failed.
|
||||
/// </summary>
|
||||
public static AirGapBundleVerificationResult InvalidSignature { get; } = new(false, "Signature verification failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signer for air-gap bundles using HMAC-SHA256.
|
||||
/// </summary>
|
||||
public sealed class AirGapBundleDsseSigner : IAirGapBundleDsseSigner
|
||||
{
|
||||
private const string DssePrefix = "DSSEv1 ";
|
||||
|
||||
private readonly IOptions<AirGapBundleDsseOptions> _options;
|
||||
private readonly ILogger<AirGapBundleDsseSigner> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AirGapBundleDsseSigner"/> class.
|
||||
/// </summary>
|
||||
public AirGapBundleDsseSigner(
|
||||
IOptions<AirGapBundleDsseOptions> options,
|
||||
ILogger<AirGapBundleDsseSigner> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEnabled => string.Equals(_options.Value.Mode, "hmac", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AirGapBundleSignatureResult?> SignAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
|
||||
if (!IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Air-gap bundle DSSE signing is disabled");
|
||||
return Task.FromResult<AirGapBundleSignatureResult?>(null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.SecretBase64))
|
||||
{
|
||||
throw new InvalidOperationException("HMAC signing mode requires SecretBase64 to be configured");
|
||||
}
|
||||
|
||||
byte[] secret;
|
||||
try
|
||||
{
|
||||
secret = Convert.FromBase64String(opts.SecretBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("SecretBase64 is not valid Base64", ex);
|
||||
}
|
||||
|
||||
// Compute PAE (Pre-Authentication Encoding) per DSSE spec
|
||||
var pae = ComputePreAuthenticationEncoding(opts.PayloadType, bundle.ManifestDigest);
|
||||
var signature = ComputeHmacSha256(secret, pae);
|
||||
var signatureBase64 = Convert.ToBase64String(signature);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed air-gap bundle {BundleId} with key {KeyId}",
|
||||
bundle.BundleId,
|
||||
opts.KeyId);
|
||||
|
||||
return Task.FromResult<AirGapBundleSignatureResult?>(
|
||||
new AirGapBundleSignatureResult(opts.KeyId, signature, signatureBase64));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AirGapBundleVerificationResult> VerifyAsync(
|
||||
AirGapBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
|
||||
if (!IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Air-gap bundle DSSE signing is disabled, skipping verification");
|
||||
return Task.FromResult(AirGapBundleVerificationResult.SigningDisabled);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundle.Signature))
|
||||
{
|
||||
_logger.LogWarning("Air-gap bundle {BundleId} has no signature", bundle.BundleId);
|
||||
return Task.FromResult(AirGapBundleVerificationResult.MissingSignature);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.SecretBase64))
|
||||
{
|
||||
throw new InvalidOperationException("HMAC signing mode requires SecretBase64 to be configured");
|
||||
}
|
||||
|
||||
byte[] secret;
|
||||
try
|
||||
{
|
||||
secret = Convert.FromBase64String(opts.SecretBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("SecretBase64 is not valid Base64", ex);
|
||||
}
|
||||
|
||||
byte[] expectedSignature;
|
||||
try
|
||||
{
|
||||
expectedSignature = Convert.FromBase64String(bundle.Signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Air-gap bundle {BundleId} has invalid Base64 signature", bundle.BundleId);
|
||||
return Task.FromResult(AirGapBundleVerificationResult.InvalidSignature);
|
||||
}
|
||||
|
||||
// Compute PAE and expected signature
|
||||
var pae = ComputePreAuthenticationEncoding(opts.PayloadType, bundle.ManifestDigest);
|
||||
var computedSignature = ComputeHmacSha256(secret, pae);
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(expectedSignature, computedSignature))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Air-gap bundle {BundleId} signature verification failed",
|
||||
bundle.BundleId);
|
||||
return Task.FromResult(AirGapBundleVerificationResult.InvalidSignature);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Air-gap bundle {BundleId} signature verified successfully",
|
||||
bundle.BundleId);
|
||||
return Task.FromResult(AirGapBundleVerificationResult.Valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE = "DSSEv1" SP len(payloadType) SP payloadType SP len(payload) SP payload
|
||||
/// where len() returns ASCII decimal length, and SP is a space character.
|
||||
/// </summary>
|
||||
private static byte[] ComputePreAuthenticationEncoding(string payloadType, string manifestDigest)
|
||||
{
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var manifestDigestBytes = Encoding.UTF8.GetBytes(manifestDigest);
|
||||
|
||||
// Format: "DSSEv1 {payloadType.Length} {payloadType} {payload.Length} {payload}"
|
||||
var paeString = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{DssePrefix}{payloadTypeBytes.Length} {payloadType} {manifestDigestBytes.Length} {manifestDigest}");
|
||||
|
||||
return Encoding.UTF8.GetBytes(paeString);
|
||||
}
|
||||
|
||||
private static byte[] ComputeHmacSha256(byte[] key, byte[] data)
|
||||
{
|
||||
using var hmac = new HMACSHA256(key);
|
||||
return hmac.ComputeHash(data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user