audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user