audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -4,6 +4,7 @@
// Part of Step 5: Graph Emission
// =============================================================================
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -27,7 +28,7 @@ public sealed class EvidenceGraph
/// Generation timestamp in ISO 8601 UTC format.
/// </summary>
[JsonPropertyName("generatedAt")]
public string GeneratedAt { get; init; } = DateTimeOffset.UnixEpoch.ToString("O");
public string GeneratedAt { get; init; } = DateTimeOffset.UnixEpoch.ToString("O", CultureInfo.InvariantCulture);
/// <summary>
/// Generator tool identifier.

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
using StellaOps.AirGap.Importer.Reconciliation.Signing;
@@ -229,7 +230,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
return new EvidenceGraph
{
GeneratedAt = generatedAtUtc.ToString("O"),
GeneratedAt = generatedAtUtc.ToString("O", CultureInfo.InvariantCulture),
Nodes = nodes,
Edges = edges,
Metadata = new EvidenceGraphMetadata

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
@@ -274,7 +275,7 @@ public sealed class ImportValidator
["bundleType"] = request.BundleType,
["bundleDigest"] = request.BundleDigest,
["manifestVersion"] = request.ManifestVersion,
["manifestCreatedAt"] = request.ManifestCreatedAt.ToString("O"),
["manifestCreatedAt"] = request.ManifestCreatedAt.ToString("O", CultureInfo.InvariantCulture),
["forceActivate"] = request.ForceActivate.ToString()
};

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -20,7 +21,7 @@ public sealed record TimeStatusDto(
public static TimeStatusDto FromStatus(TimeStatus status)
{
return new TimeStatusDto(
status.Anchor.AnchorTime.ToUniversalTime().ToString("O"),
status.Anchor.AnchorTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
status.Anchor.Format,
status.Anchor.Source,
status.Anchor.SignatureFingerprint,
@@ -31,7 +32,7 @@ public sealed record TimeStatusDto(
status.Staleness.IsWarning,
status.Staleness.IsBreach,
status.ContentStaleness,
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
status.EvaluatedAtUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
public string ToJson()

View File

@@ -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++;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -11,10 +11,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,242 @@
// <copyright file="AirGapBundleDsseSignerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="AirGapBundleDsseSigner"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class AirGapBundleDsseSignerTests
{
private static readonly string TestSecretBase64 = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(32));
[Fact]
public async Task SignAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions { Mode = "none" });
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result = await signer.SignAsync(bundle);
// Assert
result.Should().BeNull();
signer.IsEnabled.Should().BeFalse();
}
[Fact]
public async Task SignAsync_WhenEnabled_ReturnsValidSignature()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64,
KeyId = "test-key"
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result = await signer.SignAsync(bundle);
// Assert
result.Should().NotBeNull();
result!.KeyId.Should().Be("test-key");
result.Signature.Should().NotBeEmpty();
result.SignatureBase64.Should().NotBeNullOrWhiteSpace();
signer.IsEnabled.Should().BeTrue();
}
[Fact]
public async Task SignAsync_DeterministicForSameInput()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result1 = await signer.SignAsync(bundle);
var result2 = await signer.SignAsync(bundle);
// Assert
result1!.SignatureBase64.Should().Be(result2!.SignatureBase64);
}
[Fact]
public async Task SignAsync_DifferentForDifferentManifest()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle1 = CreateTestBundle(manifestDigest: "sha256:aaa");
var bundle2 = CreateTestBundle(manifestDigest: "sha256:bbb");
// Act
var result1 = await signer.SignAsync(bundle1);
var result2 = await signer.SignAsync(bundle2);
// Assert
result1!.SignatureBase64.Should().NotBe(result2!.SignatureBase64);
}
[Fact]
public async Task VerifyAsync_WhenDisabled_ReturnsSigningDisabled()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions { Mode = "none" });
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result = await signer.VerifyAsync(bundle);
// Assert
result.Should().Be(AirGapBundleVerificationResult.SigningDisabled);
}
[Fact]
public async Task VerifyAsync_WhenNoSignature_ReturnsMissingSignature()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle(signature: null);
// Act
var result = await signer.VerifyAsync(bundle);
// Assert
result.Should().Be(AirGapBundleVerificationResult.MissingSignature);
}
[Fact]
public async Task VerifyAsync_WithValidSignature_ReturnsValid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Sign the bundle first
var signResult = await signer.SignAsync(bundle);
var signedBundle = bundle with { Signature = signResult!.SignatureBase64, SignedBy = signResult.KeyId };
// Act
var verifyResult = await signer.VerifyAsync(signedBundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.Valid);
}
[Fact]
public async Task VerifyAsync_WithTamperedSignature_ReturnsInvalid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Sign and then tamper
var signResult = await signer.SignAsync(bundle);
var tamperedBundle = bundle with
{
Signature = signResult!.SignatureBase64,
ManifestDigest = "sha256:tampered"
};
// Act
var verifyResult = await signer.VerifyAsync(tamperedBundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
[Fact]
public async Task VerifyAsync_WithInvalidBase64Signature_ReturnsInvalid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle(signature: "not-valid-base64!!!");
// Act
var verifyResult = await signer.VerifyAsync(bundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
[Fact]
public void SignAsync_WithMissingSecret_ThrowsInvalidOperation()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = null
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act & Assert
var act = async () => await signer.SignAsync(bundle);
act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*SecretBase64*");
}
private static AirGapBundle CreateTestBundle(
string? manifestDigest = null,
string? signature = null)
{
return new AirGapBundle
{
BundleId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
TenantId = "test-tenant",
CreatedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z"),
CreatedByNodeId = "test-node",
JobLogs = new List<NodeJobLog>(),
ManifestDigest = manifestDigest ?? "sha256:abc123def456",
Signature = signature
};
}
}

View File

@@ -11,10 +11,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View File

@@ -95,6 +95,7 @@ public class Rfc3161VerifierTests
Assert.False(result.IsValid);
// Should report either decode or error
Assert.True(result.Reason?.Contains("rfc3161-") ?? false);
Assert.NotNull(result.Reason);
Assert.Contains("rfc3161-", result.Reason);
}
}