up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Signer.Core;
/// <summary>
/// Well-known predicate type URIs used in StellaOps attestations.
/// </summary>
public static class PredicateTypes
{
/// <summary>
/// SLSA Provenance v0.2 predicate type.
/// </summary>
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
/// <summary>
/// SLSA Provenance v1.0 predicate type.
/// </summary>
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
/// <summary>
/// StellaOps Promotion attestation predicate type.
/// </summary>
public const string StellaOpsPromotion = "stella.ops/promotion@v1";
/// <summary>
/// StellaOps SBOM attestation predicate type.
/// </summary>
public const string StellaOpsSbom = "stella.ops/sbom@v1";
/// <summary>
/// StellaOps VEX attestation predicate type.
/// </summary>
public const string StellaOpsVex = "stella.ops/vex@v1";
/// <summary>
/// StellaOps Replay manifest attestation predicate type.
/// </summary>
public const string StellaOpsReplay = "stella.ops/replay@v1";
/// <summary>
/// StellaOps Policy evaluation result predicate type.
/// </summary>
public const string StellaOpsPolicy = "stella.ops/policy@v1";
/// <summary>
/// StellaOps Evidence chain predicate type.
/// </summary>
public const string StellaOpsEvidence = "stella.ops/evidence@v1";
/// <summary>
/// StellaOps VEX Decision predicate type for OpenVEX policy decisions.
/// Used by Policy Engine to sign per-finding OpenVEX statements with reachability evidence.
/// </summary>
public const string StellaOpsVexDecision = "stella.ops/vexDecision@v1";
/// <summary>
/// StellaOps Graph predicate type for reachability call-graph attestations.
/// Used by Scanner to sign richgraph-v1 manifests with deterministic ordering.
/// </summary>
public const string StellaOpsGraph = "stella.ops/graph@v1";
/// <summary>
/// CycloneDX SBOM predicate type.
/// </summary>
public const string CycloneDxSbom = "https://cyclonedx.org/bom";
/// <summary>
/// SPDX SBOM predicate type.
/// </summary>
public const string SpdxSbom = "https://spdx.dev/Document";
/// <summary>
/// OpenVEX predicate type.
/// </summary>
public const string OpenVex = "https://openvex.dev/ns";
/// <summary>
/// Determines if the predicate type is a well-known StellaOps type.
/// </summary>
public static bool IsStellaOpsType(string predicateType)
{
return predicateType?.StartsWith("stella.ops/", StringComparison.Ordinal) == true;
}
/// <summary>
/// Determines if the predicate type is a SLSA provenance type.
/// </summary>
public static bool IsSlsaProvenance(string predicateType)
{
return predicateType?.StartsWith("https://slsa.dev/provenance/", StringComparison.Ordinal) == true;
}
/// <summary>
/// Determines if the predicate type is a VEX-related type that should contain OpenVEX payload.
/// </summary>
public static bool IsVexRelatedType(string predicateType)
{
return predicateType == StellaOpsVex
|| predicateType == StellaOpsVexDecision
|| predicateType == OpenVex;
}
/// <summary>
/// Determines if the predicate type is a reachability-related type.
/// </summary>
public static bool IsReachabilityRelatedType(string predicateType)
{
return predicateType == StellaOpsGraph
|| predicateType == StellaOpsReplay
|| predicateType == StellaOpsEvidence;
}
/// <summary>
/// Gets the list of all allowed predicate types for the Signer.
/// </summary>
public static IReadOnlyList<string> GetAllowedPredicateTypes()
{
return new[]
{
// SLSA types
SlsaProvenanceV02,
SlsaProvenanceV1,
// StellaOps types
StellaOpsPromotion,
StellaOpsSbom,
StellaOpsVex,
StellaOpsReplay,
StellaOpsPolicy,
StellaOpsEvidence,
StellaOpsVexDecision,
StellaOpsGraph,
// Third-party types
CycloneDxSbom,
SpdxSbom,
OpenVex
};
}
/// <summary>
/// Determines if the predicate type is an allowed/known type.
/// </summary>
public static bool IsAllowedPredicateType(string predicateType)
{
return GetAllowedPredicateTypes().Contains(predicateType);
}
}

View File

@@ -1,61 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Provenance.Attestation;
namespace StellaOps.Signer.Core;
/// <summary>
/// Builder for in-toto statement payloads with support for StellaOps predicate types.
/// Delegates canonicalization to the Provenance library for deterministic serialization.
/// </summary>
public static class SignerStatementBuilder
{
private const string StatementType = "https://in-toto.io/Statement/v0.1";
private const string InTotoStatementTypeV01 = "https://in-toto.io/Statement/v0.1";
private const string InTotoStatementTypeV1 = "https://in-toto.io/Statement/v1";
/// <summary>
/// Builds an in-toto statement payload from a signing request.
/// Uses canonical JSON serialization for deterministic output.
/// </summary>
/// <param name="request">The signing request containing subjects and predicate.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return BuildStatementPayload(request, InTotoStatementTypeV01);
}
var subjects = new List<object>(request.Subjects.Count);
foreach (var subject in request.Subjects)
/// <summary>
/// Builds an in-toto statement payload with explicit statement type version.
/// </summary>
/// <param name="request">The signing request.</param>
/// <param name="statementType">The in-toto statement type URI.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request, string statementType)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(statementType);
var statement = BuildStatement(request, statementType);
return SerializeCanonical(statement);
}
/// <summary>
/// Builds an in-toto statement object from a signing request.
/// </summary>
public static InTotoStatement BuildStatement(SigningRequest request, string? statementType = null)
{
ArgumentNullException.ThrowIfNull(request);
var subjects = BuildSubjects(request.Subjects);
var predicateType = NormalizePredicateType(request.PredicateType);
return new InTotoStatement(
Type: statementType ?? InTotoStatementTypeV01,
PredicateType: predicateType,
Subject: subjects,
Predicate: request.Predicate.RootElement);
}
/// <summary>
/// Builds statement subjects with canonicalized digest entries.
/// </summary>
private static IReadOnlyList<InTotoSubject> BuildSubjects(IReadOnlyList<SigningSubject> requestSubjects)
{
var subjects = new List<InTotoSubject>(requestSubjects.Count);
foreach (var subject in requestSubjects)
{
// Sort digest keys and normalize to lowercase for determinism
var digest = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var kvp in subject.Digest)
{
digest[kvp.Key.ToLowerInvariant()] = kvp.Value;
}
subjects.Add(new
{
name = subject.Name,
digest
});
subjects.Add(new InTotoSubject(subject.Name, digest));
}
var statement = new
{
_type = StatementType,
predicateType = request.PredicateType,
subject = subjects,
predicate = request.Predicate.RootElement.Clone()
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false,
};
options.Converters.Add(new JsonElementConverter());
return JsonSerializer.SerializeToUtf8Bytes(statement, options);
return subjects;
}
private sealed class JsonElementConverter : System.Text.Json.Serialization.JsonConverter<JsonElement>
/// <summary>
/// Normalizes predicate type URIs for consistency.
/// </summary>
private static string NormalizePredicateType(string predicateType)
{
public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
// Normalize common variations
return predicateType.Trim();
}
/// <summary>
/// Serializes the statement to canonical JSON bytes using Provenance library.
/// </summary>
private static byte[] SerializeCanonical(InTotoStatement statement)
{
// Build the statement object for serialization
var statementObj = new
{
using var document = JsonDocument.ParseValue(ref reader);
return document.RootElement.Clone();
_type = statement.Type,
predicateType = statement.PredicateType,
subject = statement.Subject.Select(s => new
{
name = s.Name,
digest = s.Digest
}).ToArray(),
predicate = statement.Predicate
};
// Use CanonicalJson from Provenance library for deterministic serialization
return CanonicalJson.SerializeToUtf8Bytes(statementObj);
}
/// <summary>
/// Validates that a predicate type is well-known and supported.
/// </summary>
/// <param name="predicateType">The predicate type URI to validate.</param>
/// <returns>True if the predicate type is well-known; false otherwise.</returns>
public static bool IsWellKnownPredicateType(string predicateType)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
return false;
}
public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options)
return PredicateTypes.IsStellaOpsType(predicateType) ||
PredicateTypes.IsSlsaProvenance(predicateType) ||
predicateType == PredicateTypes.CycloneDxSbom ||
predicateType == PredicateTypes.SpdxSbom ||
predicateType == PredicateTypes.OpenVex;
}
/// <summary>
/// Gets the recommended statement type version for a given predicate type.
/// </summary>
/// <param name="predicateType">The predicate type URI.</param>
/// <returns>The recommended in-toto statement type URI.</returns>
public static string GetRecommendedStatementType(string predicateType)
{
// SLSA v1 and StellaOps types should use Statement v1
if (predicateType == PredicateTypes.SlsaProvenanceV1 ||
PredicateTypes.IsStellaOpsType(predicateType))
{
value.WriteTo(writer);
return InTotoStatementTypeV1;
}
// Default to v0.1 for backwards compatibility
return InTotoStatementTypeV01;
}
}
/// <summary>
/// Represents an in-toto statement.
/// </summary>
public sealed record InTotoStatement(
string Type,
string PredicateType,
IReadOnlyList<InTotoSubject> Subject,
JsonElement Predicate);
/// <summary>
/// Represents a subject in an in-toto statement.
/// </summary>
public sealed record InTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);

View File

@@ -6,4 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Signing;
/// <summary>
/// DSSE signer implementation that uses StellaOps.Cryptography providers
/// for keyless (ephemeral) or KMS-backed signing operations.
/// Produces cosign-compatible DSSE envelopes.
/// </summary>
public sealed class CryptoDsseSigner : IDsseSigner
{
private const string DssePayloadType = "application/vnd.in-toto+json";
private const string PreAuthenticationEncodingPrefix = "DSSEv1";
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly ISigningKeyResolver _keyResolver;
private readonly ILogger<CryptoDsseSigner> _logger;
private readonly DsseSignerOptions _options;
public CryptoDsseSigner(
ICryptoProviderRegistry cryptoRegistry,
ISigningKeyResolver keyResolver,
IOptions<DsseSignerOptions> options,
ILogger<CryptoDsseSigner> logger)
{
_cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
_keyResolver = keyResolver ?? throw new ArgumentNullException(nameof(keyResolver));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var signingMode = request.Options.Mode;
var algorithmId = ResolveAlgorithm(signingMode);
_logger.LogDebug(
"Starting DSSE signing for tenant {Tenant} with mode {Mode} and algorithm {Algorithm}",
caller.Tenant,
signingMode,
algorithmId);
// Build the in-toto statement payload
var statementPayload = SignerStatementBuilder.BuildStatementPayload(request);
// Encode payload as base64url for DSSE
var payloadBase64 = Convert.ToBase64String(statementPayload)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
// Build PAE (Pre-Authentication Encoding) for signing
var paeBytes = BuildPae(DssePayloadType, statementPayload);
// Resolve signing key and provider
var keyResolution = await _keyResolver
.ResolveKeyAsync(signingMode, caller.Tenant, cancellationToken)
.ConfigureAwait(false);
var keyReference = new CryptoKeyReference(keyResolution.KeyId, keyResolution.ProviderHint);
// Get signer from crypto registry
var signerResolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
algorithmId,
keyReference,
keyResolution.ProviderHint);
var signer = signerResolution.Signer;
// Sign the PAE
var signatureBytes = await signer
.SignAsync(paeBytes, cancellationToken)
.ConfigureAwait(false);
// Encode signature as base64url (cosign-compatible)
var signatureBase64 = Convert.ToBase64String(signatureBytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
_logger.LogInformation(
"DSSE signing completed for tenant {Tenant} using provider {Provider} with key {KeyId}",
caller.Tenant,
signerResolution.ProviderName,
signer.KeyId);
// Build certificate chain if available
var certChain = BuildCertificateChain(signer, keyResolution);
// Build DSSE envelope
var envelope = new DsseEnvelope(
Payload: payloadBase64,
PayloadType: DssePayloadType,
Signatures:
[
new DsseSignature(
Signature: signatureBase64,
KeyId: signer.KeyId)
]);
// Build signing metadata
var identity = new SigningIdentity(
Mode: signingMode.ToString().ToLowerInvariant(),
Issuer: keyResolution.Issuer ?? _options.DefaultIssuer,
Subject: keyResolution.Subject ?? caller.Subject,
ExpiresAtUtc: keyResolution.ExpiresAtUtc);
var metadata = new SigningMetadata(
Identity: identity,
CertificateChain: certChain,
ProviderName: signerResolution.ProviderName,
AlgorithmId: algorithmId);
return new SigningBundle(envelope, metadata);
}
/// <summary>
/// Builds the PAE (Pre-Authentication Encoding) as per DSSE specification.
/// PAE = "DSSEv1" || SP || LEN(type) || SP || type || SP || LEN(payload) || SP || payload
/// where SP is space (0x20) and LEN is decimal ASCII length.
/// </summary>
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
// Calculate total length
var prefixBytes = Encoding.UTF8.GetBytes(PreAuthenticationEncodingPrefix);
var typeLenStr = typeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLen = prefixBytes.Length + 1 +
typeLenStr.Length + 1 +
typeBytes.Length + 1 +
payloadLenStr.Length + 1 +
payload.Length;
var pae = new byte[totalLen];
var offset = 0;
// DSSEv1
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
offset += prefixBytes.Length;
pae[offset++] = 0x20; // space
// LEN(type)
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
offset += typeLenBytes.Length;
pae[offset++] = 0x20; // space
// type
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
offset += typeBytes.Length;
pae[offset++] = 0x20; // space
// LEN(payload)
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
offset += payloadLenBytes.Length;
pae[offset++] = 0x20; // space
// payload
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
return pae;
}
private string ResolveAlgorithm(SigningMode mode)
{
return mode switch
{
SigningMode.Keyless => _options.KeylessAlgorithm ?? SignatureAlgorithms.Es256,
SigningMode.Kms => _options.KmsAlgorithm ?? SignatureAlgorithms.Es256,
_ => SignatureAlgorithms.Es256
};
}
private static IReadOnlyList<string> BuildCertificateChain(
ICryptoSigner signer,
SigningKeyResolution keyResolution)
{
var chain = new List<string>();
// Export public key as JWK for verification
try
{
var jwk = signer.ExportPublicJsonWebKey();
if (jwk is not null)
{
// Convert JWK to PEM-like representation for certificate chain
// In keyless mode, this represents the ephemeral signing certificate
var jwkJson = System.Text.Json.JsonSerializer.Serialize(jwk);
chain.Add(Convert.ToBase64String(Encoding.UTF8.GetBytes(jwkJson)));
}
}
catch
{
// Some signers may not support JWK export
}
// Add any additional certificates from key resolution
if (keyResolution.CertificateChain is { Count: > 0 })
{
chain.AddRange(keyResolution.CertificateChain);
}
return chain;
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Signing;
/// <summary>
/// Default signing key resolver that supports both keyless (ephemeral) and KMS modes.
/// </summary>
public sealed class DefaultSigningKeyResolver : ISigningKeyResolver
{
private const string KeylessKeyIdPrefix = "ephemeral:";
private const int KeylessExpiryMinutes = 10;
private readonly DsseSignerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultSigningKeyResolver> _logger;
public DefaultSigningKeyResolver(
IOptions<DsseSignerOptions> options,
TimeProvider timeProvider,
ILogger<DefaultSigningKeyResolver> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<SigningKeyResolution> ResolveKeyAsync(
SigningMode mode,
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
var resolution = mode switch
{
SigningMode.Keyless => ResolveKeylessKey(tenant),
SigningMode.Kms => ResolveKmsKey(tenant),
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported signing mode")
};
_logger.LogDebug(
"Resolved signing key {KeyId} for tenant {Tenant} in mode {Mode}",
resolution.KeyId,
tenant,
mode);
return ValueTask.FromResult(resolution);
}
private SigningKeyResolution ResolveKeylessKey(string tenant)
{
// Generate ephemeral key identifier using timestamp for uniqueness
var now = _timeProvider.GetUtcNow();
var keyId = $"{KeylessKeyIdPrefix}{tenant}:{now:yyyyMMddHHmmss}:{Guid.NewGuid():N}";
var expiresAt = now.AddMinutes(KeylessExpiryMinutes);
return new SigningKeyResolution(
KeyId: keyId,
ProviderHint: _options.PreferredProvider,
Issuer: _options.DefaultIssuer,
Subject: $"keyless:{tenant}",
ExpiresAtUtc: expiresAt);
}
private SigningKeyResolution ResolveKmsKey(string tenant)
{
// Check for tenant-specific KMS key
string? kmsKeyId = null;
if (_options.TenantKmsKeys.TryGetValue(tenant, out var tenantKey))
{
kmsKeyId = tenantKey;
}
else if (!string.IsNullOrWhiteSpace(_options.DefaultKmsKeyId))
{
kmsKeyId = _options.DefaultKmsKeyId;
}
if (string.IsNullOrWhiteSpace(kmsKeyId))
{
throw new InvalidOperationException(
$"No KMS key configured for tenant '{tenant}' and no default KMS key is set.");
}
return new SigningKeyResolution(
KeyId: kmsKeyId,
ProviderHint: _options.PreferredProvider,
Issuer: _options.DefaultIssuer,
Subject: $"kms:{tenant}");
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Signing;
/// <summary>
/// Configuration options for the DSSE signer.
/// </summary>
public sealed class DsseSignerOptions
{
/// <summary>
/// Gets or sets the default algorithm for keyless (ephemeral) signing.
/// Defaults to ES256.
/// </summary>
public string? KeylessAlgorithm { get; set; }
/// <summary>
/// Gets or sets the default algorithm for KMS-backed signing.
/// Defaults to ES256.
/// </summary>
public string? KmsAlgorithm { get; set; }
/// <summary>
/// Gets or sets the default issuer for signing identity metadata.
/// </summary>
public string DefaultIssuer { get; set; } = "https://stellaops.io";
/// <summary>
/// Gets or sets the default KMS key identifier for KMS signing mode.
/// </summary>
public string? DefaultKmsKeyId { get; set; }
/// <summary>
/// Gets or sets the preferred crypto provider name.
/// When null, the registry uses its default ordering.
/// </summary>
public string? PreferredProvider { get; set; }
/// <summary>
/// Gets or sets per-tenant KMS key mappings.
/// Key is tenant identifier, value is KMS key ID.
/// </summary>
public Dictionary<string, string> TenantKmsKeys { get; set; } = new();
/// <summary>
/// Gets or sets whether to include JWK in certificate chain output.
/// Defaults to true.
/// </summary>
public bool IncludeJwkInChain { get; set; } = true;
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Signing;
/// <summary>
/// Resolves signing keys based on signing mode and tenant context.
/// </summary>
public interface ISigningKeyResolver
{
/// <summary>
/// Resolves the signing key for the given mode and tenant.
/// </summary>
/// <param name="mode">The signing mode (Keyless or KMS).</param>
/// <param name="tenant">The tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved key information.</returns>
ValueTask<SigningKeyResolution> ResolveKeyAsync(
SigningMode mode,
string tenant,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of key resolution containing key reference and identity metadata.
/// </summary>
public sealed record SigningKeyResolution(
string KeyId,
string? ProviderHint = null,
string? Issuer = null,
string? Subject = null,
DateTimeOffset? ExpiresAtUtc = null,
IReadOnlyList<string>? CertificateChain = null);

View File

@@ -0,0 +1,83 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Signing;
/// <summary>
/// Extension methods for registering signing services with dependency injection.
/// </summary>
public static class SigningServiceCollectionExtensions
{
/// <summary>
/// Adds the DSSE signing services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for signer options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDsseSigning(
this IServiceCollection services,
Action<DsseSignerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
// Register options
var optionsBuilder = services.AddOptions<DsseSignerOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
// Register time provider if not already registered
services.TryAddSingleton(TimeProvider.System);
// Register signing key resolver
services.TryAddSingleton<ISigningKeyResolver, DefaultSigningKeyResolver>();
// Register DSSE signer
services.TryAddSingleton<IDsseSigner, CryptoDsseSigner>();
return services;
}
/// <summary>
/// Adds the DSSE signing services with KMS configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="defaultKmsKeyId">The default KMS key identifier.</param>
/// <param name="configure">Additional configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDsseSigningWithKms(
this IServiceCollection services,
string defaultKmsKeyId,
Action<DsseSignerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(defaultKmsKeyId);
return services.AddDsseSigning(options =>
{
options.DefaultKmsKeyId = defaultKmsKeyId;
configure?.Invoke(options);
});
}
/// <summary>
/// Adds the DSSE signing services configured for keyless (ephemeral) signing only.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="issuer">The issuer URL for keyless certificates.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDsseSigningKeyless(
this IServiceCollection services,
string issuer = "https://stellaops.io")
{
ArgumentNullException.ThrowIfNull(services);
return services.AddDsseSigning(options =>
{
options.DefaultIssuer = issuer;
});
}
}

View File

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Deterministic test data constants for reproducible test execution.
/// All values are fixed and should not change between test runs.
/// </summary>
public static class DeterministicTestData
{
// Trusted scanner digests
public const string TrustedScannerDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
public const string UntrustedScannerDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
// Default subject data
public const string DefaultSubjectName = "ghcr.io/stellaops/scanner:v2.5.0";
public const string DefaultSubjectDigest = "abc123def456789012345678901234567890abcdef1234567890abcdef123456";
// Additional subject data for multi-subject tests
public const string SecondSubjectName = "ghcr.io/stellaops/sbomer:v1.8.0";
public const string SecondSubjectDigest = "def456789012345678901234567890abcdef1234567890abcdef123456abc123";
public const string ThirdSubjectName = "ghcr.io/stellaops/policy-engine:v2.1.0";
public const string ThirdSubjectDigest = "789012345678901234567890abcdef1234567890abcdef123456abc123def456";
// Proof of entitlement tokens
public const string ValidPoeToken = "valid-poe-token-12345";
public const string ExpiredPoeToken = "expired-poe-token-99999";
public const string InvalidPoeToken = "invalid-poe-token-00000";
// Tenant identifiers
public const string DefaultTenant = "stellaops-default";
public const string TestTenant = "test-tenant-12345";
public const string EnterpriseCustomerTenant = "enterprise-customer-67890";
// Key identifiers
public const string KeylessKeyId = "keyless-ephemeral-20250115";
public const string KmsKeyId = "alias/stellaops-signing-key";
public const string TestKmsKeyId = "test-kms-key-12345";
// Issuer/subject for signing identity
public const string DefaultIssuer = "https://signer.stellaops.io";
public const string FulcioIssuer = "https://fulcio.sigstore.dev";
public const string TestIssuer = "https://test.signer.local";
// Fixed timestamps for deterministic testing
public static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
public static readonly DateTimeOffset ExpiryTimestamp = new(2025, 1, 15, 11, 30, 0, TimeSpan.Zero);
public static readonly DateTimeOffset FarFutureExpiry = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
// License/entitlement data
public const string TestLicenseId = "LIC-TEST-12345";
public const string TestCustomerId = "CUST-TEST-67890";
public const string ProPlan = "pro";
public const string EnterprisePlan = "enterprise";
public const int DefaultMaxArtifactBytes = 128 * 1024;
public const int DefaultQpsLimit = 10;
public const int DefaultQpsRemaining = 10;
/// <summary>
/// Creates a default caller context for testing.
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
/// </summary>
public static CallerContext CreateDefaultCallerContext()
{
return new CallerContext(
Subject: "test-service@stellaops.io",
Tenant: DefaultTenant,
Scopes: new[] { "signer.sign", "signer.verify" },
Audiences: new[] { "signer", "https://signer.stellaops.io" },
SenderBinding: "dpop-proof-12345",
ClientCertificateThumbprint: null);
}
/// <summary>
/// Creates a caller context for a specific tenant.
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
/// </summary>
public static CallerContext CreateCallerContextForTenant(string tenant)
{
return new CallerContext(
Subject: $"service@{tenant}.stellaops.io",
Tenant: tenant,
Scopes: new[] { "signer.sign", "signer.verify" },
Audiences: new[] { "signer", "https://signer.stellaops.io" },
SenderBinding: "dpop-proof-12345",
ClientCertificateThumbprint: null);
}
/// <summary>
/// Creates a default proof of entitlement result.
/// </summary>
public static ProofOfEntitlementResult CreateDefaultEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: TestLicenseId,
CustomerId: TestCustomerId,
Plan: ProPlan,
MaxArtifactBytes: DefaultMaxArtifactBytes,
QpsLimit: DefaultQpsLimit,
QpsRemaining: DefaultQpsRemaining,
ExpiresAtUtc: FarFutureExpiry);
}
/// <summary>
/// Creates an entitlement result for a specific plan.
/// </summary>
public static ProofOfEntitlementResult CreateEntitlementForPlan(string plan, int maxArtifactBytes = DefaultMaxArtifactBytes)
{
return new ProofOfEntitlementResult(
LicenseId: $"LIC-{plan.ToUpperInvariant()}",
CustomerId: TestCustomerId,
Plan: plan,
MaxArtifactBytes: maxArtifactBytes,
QpsLimit: DefaultQpsLimit,
QpsRemaining: DefaultQpsRemaining,
ExpiresAtUtc: FarFutureExpiry);
}
/// <summary>
/// Creates a list of default signing subjects.
/// </summary>
public static IReadOnlyList<SigningSubject> CreateDefaultSubjects()
{
return new[]
{
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest
})
};
}
/// <summary>
/// Creates multiple signing subjects for multi-subject tests.
/// </summary>
public static IReadOnlyList<SigningSubject> CreateMultipleSubjects()
{
return new[]
{
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest
}),
new SigningSubject(SecondSubjectName, new Dictionary<string, string>
{
["sha256"] = SecondSubjectDigest
}),
new SigningSubject(ThirdSubjectName, new Dictionary<string, string>
{
["sha256"] = ThirdSubjectDigest
})
};
}
/// <summary>
/// Creates a signing subject with multiple digest algorithms.
/// </summary>
public static SigningSubject CreateSubjectWithMultipleDigests()
{
return new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest,
["sha512"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
["sha384"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
});
}
}

View File

@@ -0,0 +1,580 @@
using System.Text.Json;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Provides deterministic test fixtures for predicate types used in signing tests.
/// All fixtures use static, reproducible data for deterministic test execution.
/// </summary>
public static class PredicateFixtures
{
/// <summary>
/// Deterministic timestamp for test reproducibility.
/// </summary>
public const string FixedTimestamp = "2025-01-15T10:30:00Z";
/// <summary>
/// Creates a StellaOps promotion predicate fixture.
/// </summary>
public static JsonDocument CreatePromotionPredicate()
{
return JsonDocument.Parse(PromotionPredicateJson);
}
/// <summary>
/// Creates a StellaOps SBOM predicate fixture.
/// </summary>
public static JsonDocument CreateSbomPredicate()
{
return JsonDocument.Parse(SbomPredicateJson);
}
/// <summary>
/// Creates a StellaOps replay predicate fixture.
/// </summary>
public static JsonDocument CreateReplayPredicate()
{
return JsonDocument.Parse(ReplayPredicateJson);
}
/// <summary>
/// Creates a SLSA provenance v0.2 predicate fixture.
/// </summary>
public static JsonDocument CreateSlsaProvenanceV02Predicate()
{
return JsonDocument.Parse(SlsaProvenanceV02PredicateJson);
}
/// <summary>
/// Creates a SLSA provenance v1 predicate fixture.
/// </summary>
public static JsonDocument CreateSlsaProvenanceV1Predicate()
{
return JsonDocument.Parse(SlsaProvenanceV1PredicateJson);
}
/// <summary>
/// Creates a StellaOps VEX predicate fixture.
/// </summary>
public static JsonDocument CreateVexPredicate()
{
return JsonDocument.Parse(VexPredicateJson);
}
/// <summary>
/// Creates a StellaOps policy predicate fixture.
/// </summary>
public static JsonDocument CreatePolicyPredicate()
{
return JsonDocument.Parse(PolicyPredicateJson);
}
/// <summary>
/// Creates a StellaOps evidence predicate fixture.
/// </summary>
public static JsonDocument CreateEvidencePredicate()
{
return JsonDocument.Parse(EvidencePredicateJson);
}
/// <summary>
/// Creates a StellaOps VEX Decision predicate fixture (OpenVEX format).
/// </summary>
public static JsonDocument CreateVexDecisionPredicate()
{
return JsonDocument.Parse(VexDecisionPredicateJson);
}
/// <summary>
/// Creates a StellaOps Graph predicate fixture for reachability call-graphs.
/// </summary>
public static JsonDocument CreateGraphPredicate()
{
return JsonDocument.Parse(GraphPredicateJson);
}
public const string PromotionPredicateJson = """
{
"version": "1.0",
"promotionId": "promo-20250115-103000-abc123",
"sourceEnvironment": {
"name": "staging",
"clusterId": "staging-us-west-2",
"namespace": "stellaops-app"
},
"targetEnvironment": {
"name": "production",
"clusterId": "prod-us-west-2",
"namespace": "stellaops-app"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"approval": {
"approvedBy": "security-team@stellaops.io",
"approvedAt": "2025-01-15T10:30:00Z",
"policy": "require-two-approvals",
"policyVersion": "v1.2.0"
},
"evidence": {
"scanCompleted": true,
"vulnerabilitiesFound": 0,
"signatureVerified": true,
"policyPassed": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string SbomPredicateJson = """
{
"version": "1.0",
"sbomId": "sbom-20250115-103000-xyz789",
"format": "spdx-json",
"formatVersion": "3.0.1",
"generator": {
"tool": "stellaops-sbomer",
"version": "1.8.0",
"timestamp": "2025-01-15T10:30:00Z"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"packages": {
"total": 127,
"direct": 24,
"transitive": 103
},
"licenses": {
"approved": ["MIT", "Apache-2.0", "BSD-3-Clause"],
"flagged": [],
"unknown": 2
},
"vulnerabilities": {
"critical": 0,
"high": 0,
"medium": 3,
"low": 12,
"informational": 5
},
"checksums": {
"sbomSha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876",
"contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string ReplayPredicateJson = """
{
"version": "1.0",
"replayId": "replay-20250115-103000-def456",
"originalScanId": "scan-20250114-090000-original",
"mode": "verification",
"inputs": {
"manifestDigest": "sha256:manifest123456789012345678901234567890abcdef12345678901234567890",
"feedPins": {
"nvd": "2025-01-14T00:00:00Z",
"osv": "2025-01-14T00:00:00Z"
},
"policyVersion": "v2.1.0",
"toolVersions": {
"trivy": "0.58.0",
"grype": "0.87.0",
"syft": "1.20.0"
}
},
"execution": {
"startedAt": "2025-01-15T10:00:00Z",
"completedAt": "2025-01-15T10:30:00Z",
"durationSeconds": 1800,
"workerCount": 4,
"deterministic": true
},
"outputs": {
"layersProcessed": 12,
"merkleRoot": "sha256:merkle0123456789abcdef0123456789abcdef0123456789abcdef01234567",
"outputDigest": "sha256:output0123456789abcdef0123456789abcdef0123456789abcdef01234567"
},
"verification": {
"inputHashMatch": true,
"outputHashMatch": true,
"determinismScore": 1.0,
"diffPaths": []
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string SlsaProvenanceV02PredicateJson = """
{
"builder": {
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0"
},
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"sha1": "abc123def456789012345678901234567890abcd"
},
"entryPoint": ".github/workflows/build.yml"
},
"parameters": {},
"environment": {
"github_actor": "stellaops-bot",
"github_event_name": "push",
"github_ref": "refs/tags/v2.5.0",
"github_repository": "stellaops/scanner",
"github_run_id": "12345678901",
"github_run_number": "456",
"github_sha": "abc123def456789012345678901234567890abcd"
}
},
"metadata": {
"buildInvocationId": "12345678901-456",
"buildStartedOn": "2025-01-15T10:00:00Z",
"buildFinishedOn": "2025-01-15T10:30:00Z",
"completeness": {
"parameters": true,
"environment": true,
"materials": true
},
"reproducible": true
},
"materials": [
{
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"sha1": "abc123def456789012345678901234567890abcd"
}
},
{
"uri": "pkg:golang/github.com/stellaops/go-sdk@v1.5.0",
"digest": {
"sha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876"
}
}
]
}
""";
public const string SlsaProvenanceV1PredicateJson = """
{
"buildDefinition": {
"buildType": "https://slsa.dev/container-based-build/v0.1",
"externalParameters": {
"repository": "https://github.com/stellaops/scanner",
"ref": "refs/tags/v2.5.0"
},
"internalParameters": {
"workflow": ".github/workflows/build.yml"
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"gitCommit": "abc123def456789012345678901234567890abcd"
}
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0",
"builderDependencies": [
{
"uri": "https://github.com/actions/runner-images/releases/tag/ubuntu22/20250110.1"
}
],
"version": {
"stellaops-builder": "1.0.0"
}
},
"metadata": {
"invocationId": "https://github.com/stellaops/scanner/actions/runs/12345678901/attempts/1",
"startedOn": "2025-01-15T10:00:00Z",
"finishedOn": "2025-01-15T10:30:00Z"
},
"byproducts": []
}
}
""";
public const string VexPredicateJson = """
{
"version": "1.0",
"vexId": "vex-20250115-103000-ghi789",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"statements": [
{
"vulnerability": "CVE-2024-12345",
"status": "not_affected",
"justification": "vulnerable_code_not_present",
"impact": "The affected function is not used in our build configuration.",
"actionStatement": "No action required.",
"timestamp": "2025-01-15T10:30:00Z"
},
{
"vulnerability": "CVE-2024-67890",
"status": "fixed",
"justification": "component_not_present",
"impact": "Dependency was removed in v2.4.0.",
"actionStatement": "Upgrade to v2.4.0 or later.",
"timestamp": "2025-01-15T10:30:00Z"
}
],
"author": {
"name": "StellaOps Security Team",
"email": "security@stellaops.io"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string PolicyPredicateJson = """
{
"version": "1.0",
"evaluationId": "eval-20250115-103000-jkl012",
"policy": {
"id": "stellaops-production-policy",
"version": "v2.1.0",
"digest": "sha256:policy0123456789abcdef0123456789abcdef0123456789abcdef01234567"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"result": {
"passed": true,
"score": 98,
"threshold": 85
},
"rules": {
"evaluated": 42,
"passed": 41,
"failed": 0,
"skipped": 1,
"warnings": 3
},
"violations": [],
"warnings": [
{
"ruleId": "warn-sbom-completeness",
"message": "SBOM completeness is 97%, recommended minimum is 99%.",
"severity": "low"
}
],
"evidence": {
"sbomVerified": true,
"signatureVerified": true,
"provenanceVerified": true,
"vulnerabilityScanPassed": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string EvidencePredicateJson = """
{
"version": "1.0",
"evidenceId": "evidence-20250115-103000-mno345",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"chain": [
{
"type": "provenance",
"digest": "sha256:prov0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "https://github.com/stellaops/scanner/.github/workflows/build.yml",
"timestamp": "2025-01-15T10:15:00Z"
},
{
"type": "sbom",
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-sbomer",
"timestamp": "2025-01-15T10:20:00Z"
},
{
"type": "vulnerability-scan",
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-scanner",
"timestamp": "2025-01-15T10:25:00Z"
},
{
"type": "policy-evaluation",
"digest": "sha256:eval0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-policy-engine",
"timestamp": "2025-01-15T10:30:00Z"
}
],
"aggregated": {
"trustLevel": "high",
"completeness": 1.0,
"validFrom": "2025-01-15T10:00:00Z",
"validUntil": "2025-07-15T10:00:00Z"
},
"verificationLog": {
"verifiedAt": "2025-01-15T10:30:00Z",
"verifiedBy": "stellaops-authority",
"rekorLogIndex": 12345678,
"transparencyLogId": "https://rekor.sigstore.dev"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
/// <summary>
/// VEX Decision predicate in OpenVEX format for policy decision signing.
/// This is the per-finding OpenVEX statement used by the Policy Engine.
/// </summary>
public const string VexDecisionPredicateJson = """
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://stellaops.io/vex/decision/20250115-103000-pqr678",
"author": "StellaOps Policy Engine",
"role": "automated-policy-engine",
"timestamp": "2025-01-15T10:30:00Z",
"version": 1,
"tooling": "stellaops-policy-engine/v2.1.0",
"statements": [
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345",
"name": "CVE-2024-12345",
"description": "Buffer overflow in example library"
},
"timestamp": "2025-01-15T10:30:00Z",
"products": [
{
"@id": "pkg:oci/scanner@sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456",
"identifiers": {
"purl": "pkg:oci/scanner@sha256:abc123"
},
"subcomponents": [
{
"@id": "pkg:npm/lodash@4.17.20",
"identifiers": {
"purl": "pkg:npm/lodash@4.17.20"
}
}
]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The vulnerable function _.template() is not called in this build. Reachability analysis confirms no execution path reaches the affected code.",
"action_statement": "No remediation required. Continue monitoring for status changes.",
"status_notes": "Determined via static reachability analysis using stellaops-scanner v2.5.0",
"supplier": "StellaOps Security Team"
}
],
"stellaops_extensions": {
"policy_id": "stellaops-production-policy",
"policy_version": "v2.1.0",
"evaluation_id": "eval-20250115-103000-jkl012",
"reachability": {
"analyzed": true,
"confidence": 0.95,
"graph_digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567",
"method": "static-callgraph"
},
"evidence_refs": [
{
"type": "sbom",
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
{
"type": "scan-report",
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
{
"type": "callgraph",
"digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567"
}
]
}
}
""";
/// <summary>
/// Graph predicate for reachability call-graph attestations (richgraph-v1 schema).
/// Used by Scanner to sign deterministic call-graph manifests.
/// </summary>
public const string GraphPredicateJson = """
{
"version": "1.0",
"schema": "richgraph-v1",
"graphId": "graph-20250115-103000-stu901",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"generation": {
"tool": "stellaops-scanner",
"toolVersion": "2.5.0",
"generatedAt": "2025-01-15T10:30:00Z",
"deterministic": true,
"hashAlgorithm": "blake3"
},
"graph": {
"rootNodes": [
{
"symbolId": "main:0x1000",
"name": "main",
"demangled": "main(int, char**)",
"source": "native",
"file": "src/main.c",
"line": 42
}
],
"nodes": {
"total": 1247,
"native": 823,
"managed": 424
},
"edges": {
"total": 3891,
"direct": 2456,
"indirect": 1435
},
"components": {
"analyzed": 156,
"purls": [
"pkg:npm/lodash@4.17.20",
"pkg:npm/express@4.18.2",
"pkg:golang/github.com/stellaops/go-sdk@v1.5.0"
]
}
},
"hashes": {
"graphHash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"nodesHash": "blake3:fedcba987654321098765432109876543210fedcba987654321098765432109876",
"edgesHash": "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
"cas": {
"location": "cas://reachability/graphs/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"bundleDigest": "sha256:bundle0123456789abcdef0123456789abcdef0123456789abcdef012345678"
},
"metadata": {
"scanId": "scan-20250115-103000-original",
"layersAnalyzed": 12,
"initRootsIncluded": true,
"purlResolutionEnabled": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
}

View File

@@ -0,0 +1,191 @@
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Builder for creating deterministic signing requests in tests.
/// Uses fixed values to ensure reproducible test results.
/// </summary>
public sealed class SigningRequestBuilder
{
private List<SigningSubject> _subjects = new();
private string _predicateType = PredicateTypes.SlsaProvenanceV02;
private JsonDocument _predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
private string _scannerImageDigest = DeterministicTestData.TrustedScannerDigest;
private SignerPoEFormat _poeFormat = SignerPoEFormat.Jwt;
private string _poeValue = DeterministicTestData.ValidPoeToken;
private SigningMode _signingMode = SigningMode.Keyless;
private int? _expirySeconds = 3600;
private string _returnBundle = "dsse+cert";
public SigningRequestBuilder WithSubject(string name, string sha256Hash)
{
_subjects.Add(new SigningSubject(name, new Dictionary<string, string>
{
["sha256"] = sha256Hash
}));
return this;
}
public SigningRequestBuilder WithSubject(string name, Dictionary<string, string> digest)
{
_subjects.Add(new SigningSubject(name, digest));
return this;
}
public SigningRequestBuilder WithDefaultSubject()
{
return WithSubject(
DeterministicTestData.DefaultSubjectName,
DeterministicTestData.DefaultSubjectDigest);
}
public SigningRequestBuilder WithPredicateType(string predicateType)
{
_predicateType = predicateType;
return this;
}
public SigningRequestBuilder WithPredicate(JsonDocument predicate)
{
_predicate = predicate;
return this;
}
public SigningRequestBuilder WithPromotionPredicate()
{
_predicateType = PredicateTypes.StellaOpsPromotion;
_predicate = PredicateFixtures.CreatePromotionPredicate();
return this;
}
public SigningRequestBuilder WithSbomPredicate()
{
_predicateType = PredicateTypes.StellaOpsSbom;
_predicate = PredicateFixtures.CreateSbomPredicate();
return this;
}
public SigningRequestBuilder WithReplayPredicate()
{
_predicateType = PredicateTypes.StellaOpsReplay;
_predicate = PredicateFixtures.CreateReplayPredicate();
return this;
}
public SigningRequestBuilder WithVexPredicate()
{
_predicateType = PredicateTypes.StellaOpsVex;
_predicate = PredicateFixtures.CreateVexPredicate();
return this;
}
public SigningRequestBuilder WithPolicyPredicate()
{
_predicateType = PredicateTypes.StellaOpsPolicy;
_predicate = PredicateFixtures.CreatePolicyPredicate();
return this;
}
public SigningRequestBuilder WithEvidencePredicate()
{
_predicateType = PredicateTypes.StellaOpsEvidence;
_predicate = PredicateFixtures.CreateEvidencePredicate();
return this;
}
public SigningRequestBuilder WithVexDecisionPredicate()
{
_predicateType = PredicateTypes.StellaOpsVexDecision;
_predicate = PredicateFixtures.CreateVexDecisionPredicate();
return this;
}
public SigningRequestBuilder WithGraphPredicate()
{
_predicateType = PredicateTypes.StellaOpsGraph;
_predicate = PredicateFixtures.CreateGraphPredicate();
return this;
}
public SigningRequestBuilder WithSlsaProvenanceV02()
{
_predicateType = PredicateTypes.SlsaProvenanceV02;
_predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
return this;
}
public SigningRequestBuilder WithSlsaProvenanceV1()
{
_predicateType = PredicateTypes.SlsaProvenanceV1;
_predicate = PredicateFixtures.CreateSlsaProvenanceV1Predicate();
return this;
}
public SigningRequestBuilder WithScannerImageDigest(string digest)
{
_scannerImageDigest = digest;
return this;
}
public SigningRequestBuilder WithProofOfEntitlement(SignerPoEFormat format, string value)
{
_poeFormat = format;
_poeValue = value;
return this;
}
public SigningRequestBuilder WithSigningMode(SigningMode mode)
{
_signingMode = mode;
return this;
}
public SigningRequestBuilder WithKeylessMode()
{
_signingMode = SigningMode.Keyless;
return this;
}
public SigningRequestBuilder WithKmsMode()
{
_signingMode = SigningMode.Kms;
return this;
}
public SigningRequestBuilder WithExpirySeconds(int? expirySeconds)
{
_expirySeconds = expirySeconds;
return this;
}
public SigningRequestBuilder WithReturnBundle(string returnBundle)
{
_returnBundle = returnBundle;
return this;
}
public SigningRequest Build()
{
// Add default subject if none specified
if (_subjects.Count == 0)
{
WithDefaultSubject();
}
return new SigningRequest(
Subjects: _subjects,
PredicateType: _predicateType,
Predicate: _predicate,
ScannerImageDigest: _scannerImageDigest,
ProofOfEntitlement: new ProofOfEntitlement(_poeFormat, _poeValue),
Options: new SigningOptions(_signingMode, _expirySeconds, _returnBundle));
}
/// <summary>
/// Creates a new builder instance.
/// </summary>
public static SigningRequestBuilder Create() => new();
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Security.Cryptography;
using StellaOps.Cryptography;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Factory for creating deterministic test crypto providers and signing keys.
/// Uses fixed seed data to ensure reproducible test results.
/// </summary>
public static class TestCryptoFactory
{
/// <summary>
/// Fixed test key ID for deterministic testing.
/// </summary>
public const string TestKeyId = "test-signing-key-12345";
/// <summary>
/// Fixed keyless key ID for ephemeral signing.
/// </summary>
public const string KeylessKeyId = "keyless-ephemeral-20250115";
/// <summary>
/// Creates a DefaultCryptoProvider with a pre-registered test signing key.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithTestKey()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateDeterministicSigningKey(TestKeyId);
provider.UpsertSigningKey(signingKey);
return provider;
}
/// <summary>
/// Creates a DefaultCryptoProvider with a keyless signing key.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithKeylessKey()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateDeterministicSigningKey(KeylessKeyId);
provider.UpsertSigningKey(signingKey);
return provider;
}
/// <summary>
/// Creates a DefaultCryptoProvider with multiple signing keys.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithMultipleKeys(params string[] keyIds)
{
var provider = new DefaultCryptoProvider();
foreach (var keyId in keyIds)
{
var signingKey = CreateDeterministicSigningKey(keyId);
provider.UpsertSigningKey(signingKey);
}
return provider;
}
/// <summary>
/// Creates a CryptoProviderRegistry with a test provider containing both test and keyless keys.
/// </summary>
public static ICryptoProviderRegistry CreateTestRegistry()
{
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
}
/// <summary>
/// Creates a CryptoProviderRegistry for keyless signing tests (includes both keys for flexibility).
/// </summary>
public static ICryptoProviderRegistry CreateKeylessRegistry()
{
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
}
/// <summary>
/// Creates a signing key with deterministic parameters.
/// The key is generated from the keyId to ensure determinism.
/// </summary>
public static CryptoSigningKey CreateDeterministicSigningKey(string keyId)
{
// Generate a P-256 key pair - using the keyId as a seed for determinism
// Note: In production this would use secure random generation
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(
reference: new CryptoKeyReference(keyId, "default"),
algorithmId: SignatureAlgorithms.Es256,
privateParameters: parameters,
createdAt: DeterministicTestData.FixedTimestamp,
expiresAt: DeterministicTestData.FarFutureExpiry,
metadata: new System.Collections.Generic.Dictionary<string, string?>
{
["purpose"] = "testing",
["environment"] = "unit-test"
});
}
/// <summary>
/// Creates a CryptoKeyReference for the default test key.
/// </summary>
public static CryptoKeyReference CreateTestKeyReference()
{
return new CryptoKeyReference(TestKeyId, "default");
}
/// <summary>
/// Creates a CryptoKeyReference for keyless mode.
/// </summary>
public static CryptoKeyReference CreateKeylessKeyReference()
{
return new CryptoKeyReference(KeylessKeyId, "default");
}
}

View File

@@ -0,0 +1,511 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for CryptoDsseSigner using real crypto providers.
/// Tests signing workflows with deterministic fixture predicates.
/// </summary>
public sealed class CryptoDsseSignerIntegrationTests
{
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly ISigningKeyResolver _keyResolver;
private readonly CryptoDsseSigner _signer;
public CryptoDsseSignerIntegrationTests()
{
_cryptoRegistry = TestCryptoFactory.CreateTestRegistry();
_keyResolver = CreateTestKeyResolver();
_signer = CreateSigner();
}
[Fact]
public async Task SignAsync_WithPromotionPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.Should().NotBeNull();
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
bundle.Envelope.Signatures.Should().HaveCount(1);
bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
bundle.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignAsync_WithSbomPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSbomPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
// Verify payload contains SBOM predicate
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsSbom);
}
[Fact]
public async Task SignAsync_WithReplayPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithReplayPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
// Verify payload contains replay predicate
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsReplay);
}
[Fact]
public async Task SignAsync_WithSlsaProvenanceV02_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV02()
.WithKmsMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Metadata.Identity.Mode.Should().Be("kms");
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.SlsaProvenanceV02);
doc.RootElement.GetProperty("_type").GetString()
.Should().Be("https://in-toto.io/Statement/v0.1");
}
[Fact]
public async Task SignAsync_WithSlsaProvenanceV1_UsesStatementV1()
{
// Arrange - use predicate with v1 expected statement type
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV1()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.SlsaProvenanceV1);
}
[Fact]
public async Task SignAsync_WithMultipleSubjects_IncludesAllInPayload()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithSubject(DeterministicTestData.DefaultSubjectName, DeterministicTestData.DefaultSubjectDigest)
.WithSubject(DeterministicTestData.SecondSubjectName, DeterministicTestData.SecondSubjectDigest)
.WithSubject(DeterministicTestData.ThirdSubjectName, DeterministicTestData.ThirdSubjectDigest)
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
[Fact]
public async Task SignAsync_WithVexPredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithVexPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsVex);
}
[Fact]
public async Task SignAsync_WithPolicyPredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPolicyPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsPolicy);
}
[Fact]
public async Task SignAsync_WithEvidencePredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithEvidencePredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsEvidence);
}
[Fact]
public async Task SignAsync_ProducesBase64UrlEncodedSignature()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - signature should be base64url (no + or / or =)
var signature = bundle.Envelope.Signatures[0].Signature;
signature.Should().NotContain("+");
signature.Should().NotContain("/");
signature.Should().NotContain("=");
}
[Fact]
public async Task SignAsync_ProducesBase64UrlEncodedPayload()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - payload should be base64url (no + or / or =)
var payload = bundle.Envelope.Payload;
payload.Should().NotContain("+");
payload.Should().NotContain("/");
payload.Should().NotContain("=");
}
[Fact]
public async Task SignAsync_IncludesCertificateChainInMetadata()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.CertificateChain.Should().NotBeEmpty();
}
[Fact]
public async Task SignAsync_SetsCorrectAlgorithmId()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
}
[Fact]
public async Task SignAsync_SetsProviderNameInMetadata()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.ProviderName.Should().Be("default");
}
[Fact]
public async Task SignAsync_WithDifferentTenants_UsesCorrectKeyResolution()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller1 = DeterministicTestData.CreateCallerContextForTenant("tenant-a");
var caller2 = DeterministicTestData.CreateCallerContextForTenant("tenant-b");
// Act
var bundle1 = await _signer.SignAsync(request, entitlement, caller1, CancellationToken.None);
var bundle2 = await _signer.SignAsync(request, entitlement, caller2, CancellationToken.None);
// Assert - both should produce valid bundles
bundle1.Should().NotBeNull();
bundle2.Should().NotBeNull();
bundle1.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
bundle2.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignAsync_Signature_IsVerifiable()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - verify we can decode and re-verify the signature
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var signatureBytes = DecodeBase64Url(bundle.Envelope.Signatures[0].Signature);
// Build PAE for verification
var paeBytes = BuildPae(bundle.Envelope.PayloadType, payloadBytes);
// Get signer for verification
var keyReference = TestCryptoFactory.CreateKeylessKeyReference();
var resolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Verification,
SignatureAlgorithms.Es256,
keyReference);
var verified = await resolution.Signer.VerifyAsync(paeBytes, signatureBytes, CancellationToken.None);
verified.Should().BeTrue();
}
private CryptoDsseSigner CreateSigner()
{
var options = Options.Create(new DsseSignerOptions
{
DefaultIssuer = DeterministicTestData.DefaultIssuer,
KeylessAlgorithm = SignatureAlgorithms.Es256,
KmsAlgorithm = SignatureAlgorithms.Es256
});
return new CryptoDsseSigner(
_cryptoRegistry,
_keyResolver,
options,
NullLogger<CryptoDsseSigner>.Instance);
}
private ISigningKeyResolver CreateTestKeyResolver()
{
var keyResolver = Substitute.For<ISigningKeyResolver>();
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(callInfo => ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.KeylessKeyId,
"default",
DeterministicTestData.DefaultIssuer,
callInfo.Arg<string>(), // tenant as subject
DeterministicTestData.ExpiryTimestamp)));
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.TestKeyId,
"default",
DeterministicTestData.DefaultIssuer,
"kms-service@stellaops.io",
DeterministicTestData.FarFutureExpiry)));
return keyResolver;
}
private static byte[] DecodeBase64Url(string base64Url)
{
// Convert base64url to standard base64
var base64 = base64Url
.Replace('-', '+')
.Replace('_', '/');
// Add padding if needed
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefixBytes = Encoding.UTF8.GetBytes("DSSEv1");
var typeLenStr = typeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLen = prefixBytes.Length + 1 +
typeLenStr.Length + 1 +
typeBytes.Length + 1 +
payloadLenStr.Length + 1 +
payload.Length;
var pae = new byte[totalLen];
var offset = 0;
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
offset += prefixBytes.Length;
pae[offset++] = 0x20;
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
offset += typeLenBytes.Length;
pae[offset++] = 0x20;
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
offset += typeBytes.Length;
pae[offset++] = 0x20;
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
offset += payloadLenBytes.Length;
pae[offset++] = 0x20;
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
return pae;
}
}

View File

@@ -0,0 +1,355 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using StellaOps.Signer.Infrastructure.Signing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for the full signer pipeline using real crypto abstraction.
/// </summary>
public sealed class SignerPipelineIntegrationTests
{
[Fact]
public async Task SignerPipeline_WithCryptoDsseSigner_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Should().NotBeNull();
outcome.Bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
outcome.Bundle.Envelope.Signatures.Should().HaveCount(1);
outcome.AuditId.Should().NotBeNullOrEmpty();
outcome.Policy.Plan.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithSbomPredicate_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSbomPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithReplayPredicate_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithReplayPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
}
[Fact]
public async Task SignerPipeline_TracksAuditEntry()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.AuditId.Should().NotBeNullOrEmpty();
// Audit ID should be a valid GUID format
Guid.TryParse(outcome.AuditId, out _).Should().BeTrue();
}
[Fact]
public async Task SignerPipeline_EnforcesPolicyCounters()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Policy.Should().NotBeNull();
outcome.Policy.Plan.Should().Be(DeterministicTestData.ProPlan);
outcome.Policy.MaxArtifactBytes.Should().BeGreaterThan(0);
}
[Fact]
public async Task SignerPipeline_WithKmsMode_UsesCorrectSigningIdentity()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV02()
.WithKmsMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Bundle.Metadata.Identity.Mode.Should().Be("kms");
outcome.Bundle.Metadata.Identity.Issuer.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithKeylessMode_UsesEphemeralKey()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Bundle.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignerPipeline_RejectsUntrustedScannerDigest()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithScannerImageDigest(DeterministicTestData.UntrustedScannerDigest)
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var act = async () => await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SignerReleaseVerificationException>();
}
[Theory]
[InlineData(PredicateTypes.StellaOpsPromotion)]
[InlineData(PredicateTypes.StellaOpsSbom)]
[InlineData(PredicateTypes.StellaOpsVex)]
[InlineData(PredicateTypes.StellaOpsReplay)]
[InlineData(PredicateTypes.StellaOpsPolicy)]
[InlineData(PredicateTypes.StellaOpsEvidence)]
[InlineData(PredicateTypes.StellaOpsVexDecision)]
[InlineData(PredicateTypes.StellaOpsGraph)]
public async Task SignerPipeline_SupportsAllStellaOpsPredicateTypes(string predicateType)
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var predicate = GetPredicateForType(predicateType);
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPredicateType(predicateType)
.WithPredicate(predicate)
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Envelope.Signatures.Should().NotBeEmpty();
}
private static ServiceCollection CreateServiceCollection()
{
var services = new ServiceCollection();
// Register logging
services.AddLogging();
// Register time provider
services.AddSingleton(TimeProvider.System);
// Register crypto registry with test keys
services.AddSingleton<ICryptoProviderRegistry>(_ =>
{
var provider = TestCryptoFactory.CreateProviderWithMultipleKeys(
TestCryptoFactory.TestKeyId,
TestCryptoFactory.KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
});
// Register key resolver
services.AddSingleton<ISigningKeyResolver>(sp =>
{
var keyResolver = Substitute.For<ISigningKeyResolver>();
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.KeylessKeyId,
"default",
DeterministicTestData.DefaultIssuer)));
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.TestKeyId,
"default",
DeterministicTestData.DefaultIssuer)));
return keyResolver;
});
// Register DSSE signer options
services.Configure<DsseSignerOptions>(options =>
{
options.DefaultIssuer = DeterministicTestData.DefaultIssuer;
options.KeylessAlgorithm = SignatureAlgorithms.Es256;
options.KmsAlgorithm = SignatureAlgorithms.Es256;
});
// Register CryptoDsseSigner
services.AddSingleton<IDsseSigner, CryptoDsseSigner>();
// Register stub services for pipeline dependencies
services.AddSingleton<IProofOfEntitlementIntrospector>(sp =>
{
var introspector = Substitute.For<IProofOfEntitlementIntrospector>();
introspector.IntrospectAsync(Arg.Any<ProofOfEntitlement>(), Arg.Any<CallerContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(DeterministicTestData.CreateDefaultEntitlement()));
return introspector;
});
services.AddSingleton<IReleaseIntegrityVerifier>(sp =>
{
var verifier = Substitute.For<IReleaseIntegrityVerifier>();
verifier.VerifyAsync(DeterministicTestData.TrustedScannerDigest, Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new ReleaseVerificationResult(true, "trusted-signer")));
verifier.VerifyAsync(DeterministicTestData.UntrustedScannerDigest, Arg.Any<CancellationToken>())
.Returns<ReleaseVerificationResult>(_ =>
throw new SignerReleaseVerificationException("release_untrusted", "Scanner digest is not trusted."));
return verifier;
});
services.AddSingleton<ISignerQuotaService>(sp =>
{
var quotaService = Substitute.For<ISignerQuotaService>();
quotaService.EnsureWithinLimitsAsync(
Arg.Any<SigningRequest>(),
Arg.Any<ProofOfEntitlementResult>(),
Arg.Any<CallerContext>(),
Arg.Any<CancellationToken>())
.Returns(ValueTask.CompletedTask);
return quotaService;
});
services.AddSingleton<ISignerAuditSink>(sp =>
{
var auditSink = Substitute.For<ISignerAuditSink>();
auditSink.WriteAsync(
Arg.Any<SigningRequest>(),
Arg.Any<SigningBundle>(),
Arg.Any<ProofOfEntitlementResult>(),
Arg.Any<CallerContext>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => ValueTask.FromResult(Guid.NewGuid().ToString()));
return auditSink;
});
// Register the pipeline
services.AddSingleton<ISignerPipeline, SignerPipeline>();
return services;
}
private static System.Text.Json.JsonDocument GetPredicateForType(string predicateType)
{
return predicateType switch
{
PredicateTypes.StellaOpsPromotion => PredicateFixtures.CreatePromotionPredicate(),
PredicateTypes.StellaOpsSbom => PredicateFixtures.CreateSbomPredicate(),
PredicateTypes.StellaOpsVex => PredicateFixtures.CreateVexPredicate(),
PredicateTypes.StellaOpsReplay => PredicateFixtures.CreateReplayPredicate(),
PredicateTypes.StellaOpsPolicy => PredicateFixtures.CreatePolicyPredicate(),
PredicateTypes.StellaOpsEvidence => PredicateFixtures.CreateEvidencePredicate(),
PredicateTypes.StellaOpsVexDecision => PredicateFixtures.CreateVexDecisionPredicate(),
PredicateTypes.StellaOpsGraph => PredicateFixtures.CreateGraphPredicate(),
_ => PredicateFixtures.CreateSlsaProvenanceV02Predicate()
};
}
}

View File

@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class CryptoDsseSignerTests
{
private readonly ICryptoProviderRegistry _mockRegistry;
private readonly ISigningKeyResolver _mockKeyResolver;
private readonly ICryptoSigner _mockCryptoSigner;
private readonly DsseSignerOptions _options;
private readonly CryptoDsseSigner _signer;
public CryptoDsseSignerTests()
{
_mockRegistry = Substitute.For<ICryptoProviderRegistry>();
_mockKeyResolver = Substitute.For<ISigningKeyResolver>();
_mockCryptoSigner = Substitute.For<ICryptoSigner>();
_options = new DsseSignerOptions
{
DefaultIssuer = "https://test.stellaops.io",
KeylessAlgorithm = SignatureAlgorithms.Es256
};
_signer = new CryptoDsseSigner(
_mockRegistry,
_mockKeyResolver,
Options.Create(_options),
NullLogger<CryptoDsseSigner>.Instance);
}
[Fact]
public async Task SignAsync_ProducesValidDsseEnvelope()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("test-key-id", "default");
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("test-key-id");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "test-key-id", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Envelope.Should().NotBeNull();
result.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
result.Envelope.Payload.Should().NotBeNullOrEmpty();
result.Envelope.Signatures.Should().HaveCount(1);
result.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
result.Envelope.Signatures[0].KeyId.Should().Be("test-key-id");
}
[Fact]
public async Task SignAsync_SetsCorrectSigningMetadata()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution(
"kms-key-123",
"default",
"https://custom.issuer.io",
"service-account@tenant.stellaops.io",
DateTimeOffset.UtcNow.AddHours(1));
var signatureBytes = new byte[] { 0xAB, 0xCD };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("kms-key-123");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "kms-key-123", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Metadata.Should().NotBeNull();
result.Metadata.ProviderName.Should().Be("kms-provider");
result.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
result.Metadata.Identity.Should().NotBeNull();
result.Metadata.Identity.Issuer.Should().Be("https://custom.issuer.io");
result.Metadata.Identity.Subject.Should().Be("service-account@tenant.stellaops.io");
result.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignAsync_UsesKmsMode_WhenRequested()
{
// Arrange
var request = CreateSigningRequest(SigningMode.Kms);
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("kms-key-abc", "kms-provider");
var signatureBytes = new byte[] { 0x11, 0x22, 0x33 };
_mockKeyResolver
.ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("kms-key-abc");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "kms-key-abc", Kty = "EC" });
_mockRegistry
.ResolveSigner(
CryptoCapability.Signing,
Arg.Any<string>(),
Arg.Is<CryptoKeyReference>(k => k.KeyId == "kms-key-abc"),
"kms-provider")
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Metadata.Identity.Mode.Should().Be("kms");
await _mockKeyResolver.Received(1).ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>());
}
[Fact]
public async Task SignAsync_ProducesCosignCompatibleBase64Url()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("test-key");
// Use signature bytes that would produce + and / in standard base64
var signatureBytes = new byte[] { 0xFB, 0xFF, 0xFE, 0x00, 0x01 };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("test-key");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "test-key", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var signature = result.Envelope.Signatures[0].Signature;
signature.Should().NotContain("+");
signature.Should().NotContain("/");
signature.Should().NotContain("=");
// Verify payload is also base64url encoded
result.Envelope.Payload.Should().NotContain("+");
result.Envelope.Payload.Should().NotContain("/");
result.Envelope.Payload.Should().NotEndWith("=");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Arrange
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act & Assert
var act = async () => await _signer.SignAsync(null!, entitlement, caller, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "request");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenEntitlementIsNull()
{
// Arrange
var request = CreateSigningRequest();
var caller = CreateCallerContext();
// Act & Assert
var act = async () => await _signer.SignAsync(request, null!, caller, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "entitlement");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenCallerIsNull()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
// Act & Assert
var act = async () => await _signer.SignAsync(request, entitlement, null!, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "caller");
}
private static SigningRequest CreateSigningRequest(SigningMode mode = SigningMode.Keyless)
{
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
return new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123def456"
})
],
PredicateType: "https://slsa.dev/provenance/v0.2",
Predicate: predicate,
ScannerImageDigest: "sha256:scanner123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."),
Options: new SigningOptions(mode, 3600, "bundle"));
}
private static ProofOfEntitlementResult CreateEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: "lic-123",
CustomerId: "cust-456",
Plan: "enterprise",
MaxArtifactBytes: 100_000_000,
QpsLimit: 100,
QpsRemaining: 95,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddHours(1));
}
private static CallerContext CreateCallerContext()
{
return new CallerContext(
Subject: "user@example.com",
Tenant: "test-tenant",
Scopes: ["signer.sign"],
Audiences: ["signer"],
SenderBinding: null,
ClientCertificateThumbprint: null);
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class DefaultSigningKeyResolverTests
{
private readonly DsseSignerOptions _options;
private readonly FakeTimeProvider _timeProvider;
private readonly DefaultSigningKeyResolver _resolver;
public DefaultSigningKeyResolverTests()
{
_options = new DsseSignerOptions
{
DefaultIssuer = "https://test.stellaops.io",
PreferredProvider = "test-provider"
};
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 11, 26, 12, 0, 0, TimeSpan.Zero));
_resolver = new DefaultSigningKeyResolver(
Options.Create(_options),
_timeProvider,
NullLogger<DefaultSigningKeyResolver>.Instance);
}
[Fact]
public async Task ResolveKeyAsync_KeylessMode_ReturnsEphemeralKey()
{
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.KeyId.Should().StartWith("ephemeral:tenant-123:");
result.ProviderHint.Should().Be("test-provider");
result.Issuer.Should().Be("https://test.stellaops.io");
result.Subject.Should().Be("keyless:tenant-123");
result.ExpiresAtUtc.Should().NotBeNull();
result.ExpiresAtUtc!.Value.Should().Be(_timeProvider.GetUtcNow().AddMinutes(10));
}
[Fact]
public async Task ResolveKeyAsync_KeylessMode_GeneratesUniqueKeyIds()
{
// Act
var result1 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
var result2 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
// Assert
result1.KeyId.Should().NotBe(result2.KeyId);
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_ReturnsDefaultKmsKey()
{
// Arrange
_options.DefaultKmsKeyId = "projects/test/locations/global/keyRings/ring/cryptoKeys/key";
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-456", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.KeyId.Should().Be("projects/test/locations/global/keyRings/ring/cryptoKeys/key");
result.ProviderHint.Should().Be("test-provider");
result.Issuer.Should().Be("https://test.stellaops.io");
result.Subject.Should().Be("kms:tenant-456");
result.ExpiresAtUtc.Should().BeNull();
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_UsesTenantSpecificKey()
{
// Arrange
_options.DefaultKmsKeyId = "default-key";
_options.TenantKmsKeys = new Dictionary<string, string>
{
["tenant-special"] = "tenant-special-key"
};
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-special", CancellationToken.None);
// Assert
result.KeyId.Should().Be("tenant-special-key");
result.Subject.Should().Be("kms:tenant-special");
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_FallsBackToDefaultKey()
{
// Arrange
_options.DefaultKmsKeyId = "fallback-key";
_options.TenantKmsKeys = new Dictionary<string, string>
{
["other-tenant"] = "other-tenant-key"
};
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-without-mapping", CancellationToken.None);
// Assert
result.KeyId.Should().Be("fallback-key");
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_ThrowsWhenNoKeyConfigured()
{
// Arrange
_options.DefaultKmsKeyId = null;
_options.TenantKmsKeys.Clear();
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-123", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.Where(e => e.Message.Contains("No KMS key configured") && e.Message.Contains("tenant-123"));
}
[Fact]
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsEmpty()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, "", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.ParamName == "tenant");
}
[Fact]
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsWhitespace()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, " ", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.ParamName == "tenant");
}
[Fact]
public async Task ResolveKeyAsync_ThrowsForUnknownSigningMode()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync((SigningMode)99, "tenant-123", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentOutOfRangeException>()
.Where(e => e.ParamName == "mode");
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,364 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Signer.Core;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class SignerStatementBuilderTests
{
[Fact]
public void BuildStatementPayload_CreatesValidStatement()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
// Assert
payload.Should().NotBeNullOrEmpty();
var json = Encoding.UTF8.GetString(payload);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v0.1");
root.GetProperty("predicateType").GetString().Should().Be("https://slsa.dev/provenance/v0.2");
root.GetProperty("subject").GetArrayLength().Should().Be(1);
}
[Fact]
public void BuildStatementPayload_UsesDeterministicSerialization()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload1 = SignerStatementBuilder.BuildStatementPayload(request);
var payload2 = SignerStatementBuilder.BuildStatementPayload(request);
// Assert - Same input should produce identical output
payload1.Should().BeEquivalentTo(payload2);
}
[Fact]
public void BuildStatementPayload_SortsDigestKeys()
{
// Arrange - Use unsorted digest keys
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["SHA512"] = "xyz789",
["sha256"] = "abc123",
["MD5"] = "def456"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert - Digest keys should be lowercase and sorted alphabetically
json.Should().Contain("\"md5\"");
json.Should().Contain("\"sha256\"");
json.Should().Contain("\"sha512\"");
// Verify order: md5 < sha256 < sha512
var md5Index = json.IndexOf("\"md5\"", StringComparison.Ordinal);
var sha256Index = json.IndexOf("\"sha256\"", StringComparison.Ordinal);
var sha512Index = json.IndexOf("\"sha512\"", StringComparison.Ordinal);
md5Index.Should().BeLessThan(sha256Index);
sha256Index.Should().BeLessThan(sha512Index);
}
[Fact]
public void BuildStatementPayload_WithExplicitStatementType_UsesProvided()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request, "https://in-toto.io/Statement/v1");
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v1");
}
[Fact]
public void BuildStatement_ReturnsInTotoStatement()
{
// Arrange
var request = CreateSigningRequest();
// Act
var statement = SignerStatementBuilder.BuildStatement(request);
// Assert
statement.Should().NotBeNull();
statement.Type.Should().Be("https://in-toto.io/Statement/v0.1");
statement.PredicateType.Should().Be(PredicateTypes.SlsaProvenanceV02);
statement.Subject.Should().HaveCount(1);
statement.Subject[0].Name.Should().Be("artifact.tar.gz");
statement.Predicate.ValueKind.Should().Be(JsonValueKind.Object);
}
[Fact]
public void BuildStatementPayload_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Act
var act = () => SignerStatementBuilder.BuildStatementPayload(null!);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("request");
}
[Fact]
public void BuildStatementPayload_WithStatementType_ThrowsWhenTypeIsEmpty()
{
// Arrange
var request = CreateSigningRequest();
// Act
var act = () => SignerStatementBuilder.BuildStatementPayload(request, "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("statementType");
}
[Theory]
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
[InlineData(PredicateTypes.StellaOpsSbom, true)]
[InlineData(PredicateTypes.StellaOpsVex, true)]
[InlineData(PredicateTypes.StellaOpsReplay, true)]
[InlineData(PredicateTypes.StellaOpsPolicy, true)]
[InlineData(PredicateTypes.StellaOpsEvidence, true)]
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
[InlineData(PredicateTypes.StellaOpsGraph, true)]
[InlineData(PredicateTypes.SlsaProvenanceV02, true)]
[InlineData(PredicateTypes.SlsaProvenanceV1, true)]
[InlineData(PredicateTypes.CycloneDxSbom, true)]
[InlineData(PredicateTypes.SpdxSbom, true)]
[InlineData(PredicateTypes.OpenVex, true)]
[InlineData("custom/predicate@v1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsWellKnownPredicateType_ReturnsExpected(string? predicateType, bool expected)
{
// Act
var result = SignerStatementBuilder.IsWellKnownPredicateType(predicateType!);
// Assert
result.Should().Be(expected);
}
[Theory]
[InlineData(PredicateTypes.SlsaProvenanceV1, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.StellaOpsPromotion, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.StellaOpsSbom, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.SlsaProvenanceV02, "https://in-toto.io/Statement/v0.1")]
[InlineData(PredicateTypes.CycloneDxSbom, "https://in-toto.io/Statement/v0.1")]
public void GetRecommendedStatementType_ReturnsCorrectVersion(string predicateType, string expectedStatementType)
{
// Act
var result = SignerStatementBuilder.GetRecommendedStatementType(predicateType);
// Assert
result.Should().Be(expectedStatementType);
}
[Fact]
public void PredicateTypes_IsStellaOpsType_IdentifiesStellaOpsTypes()
{
// Assert
PredicateTypes.IsStellaOpsType("stella.ops/promotion@v1").Should().BeTrue();
PredicateTypes.IsStellaOpsType("stella.ops/custom@v2").Should().BeTrue();
PredicateTypes.IsStellaOpsType("https://slsa.dev/provenance/v1").Should().BeFalse();
PredicateTypes.IsStellaOpsType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsSlsaProvenance_IdentifiesSlsaTypes()
{
// Assert
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v0.2").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v1").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v2").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("stella.ops/promotion@v1").Should().BeFalse();
PredicateTypes.IsSlsaProvenance(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsVexRelatedType_IdentifiesVexTypes()
{
// Assert
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVex).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVexDecision).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.OpenVex).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsGraph).Should().BeFalse();
PredicateTypes.IsVexRelatedType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsReachabilityRelatedType_IdentifiesReachabilityTypes()
{
// Assert
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsGraph).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsReplay).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsEvidence).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsVex).Should().BeFalse();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
PredicateTypes.IsReachabilityRelatedType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_GetAllowedPredicateTypes_ReturnsAllKnownTypes()
{
// Act
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes();
// Assert
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPromotion);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbom);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVex);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReplay);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPolicy);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsEvidence);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDecision);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsGraph);
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV02);
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV1);
allowedTypes.Should().Contain(PredicateTypes.CycloneDxSbom);
allowedTypes.Should().Contain(PredicateTypes.SpdxSbom);
allowedTypes.Should().Contain(PredicateTypes.OpenVex);
allowedTypes.Should().HaveCount(13);
}
[Theory]
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
[InlineData(PredicateTypes.StellaOpsGraph, true)]
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
[InlineData("custom/predicate@v1", false)]
[InlineData("", false)]
public void PredicateTypes_IsAllowedPredicateType_ReturnsExpected(string predicateType, bool expected)
{
// Act
var result = PredicateTypes.IsAllowedPredicateType(predicateType);
// Assert
result.Should().Be(expected);
}
[Fact]
public void BuildStatementPayload_HandlesMultipleSubjects()
{
// Arrange
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact1.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash1"
}),
new SigningSubject("artifact2.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash2"
}),
new SigningSubject("artifact3.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash3"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
[Fact]
public void BuildStatementPayload_PreservesPredicateContent()
{
// Arrange
var predicateContent = """
{
"builder": { "id": "https://github.com/actions" },
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/test/repo@refs/heads/main"
}
}
}
""";
var predicate = JsonDocument.Parse(predicateContent);
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
var resultPredicate = doc.RootElement.GetProperty("predicate");
resultPredicate.GetProperty("builder").GetProperty("id").GetString()
.Should().Be("https://github.com/actions");
resultPredicate.GetProperty("buildType").GetString()
.Should().Be("https://github.com/Attestations/GitHubActionsWorkflow@v1");
}
private static SigningRequest CreateSigningRequest()
{
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
return new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123def456"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, 3600, "bundle"));
}
}

View File

@@ -0,0 +1,182 @@
using System;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class SigningServiceCollectionExtensionsTests
{
[Fact]
public void AddDsseSigning_RegistersRequiredServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<ISigningKeyResolver>().Should().NotBeNull();
provider.GetService<IOptions<DsseSignerOptions>>().Should().NotBeNull();
}
[Fact]
public void AddDsseSigning_AllowsCustomConfiguration()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning(options =>
{
options.DefaultIssuer = "https://custom.issuer.io";
options.KeylessAlgorithm = "ES384";
});
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://custom.issuer.io");
options.KeylessAlgorithm.Should().Be("ES384");
}
[Fact]
public void AddDsseSigningWithKms_SetsDefaultKmsKeyId()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningWithKms("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultKmsKeyId.Should().Be("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
}
[Fact]
public void AddDsseSigningWithKms_AllowsAdditionalConfiguration()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningWithKms(
"default-key",
options => options.PreferredProvider = "kms-provider");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultKmsKeyId.Should().Be("default-key");
options.PreferredProvider.Should().Be("kms-provider");
}
[Fact]
public void AddDsseSigningKeyless_SetsDefaultIssuer()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningKeyless("https://keyless.stellaops.io");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://keyless.stellaops.io");
}
[Fact]
public void AddDsseSigningKeyless_UsesDefaultIssuerWhenNotSpecified()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningKeyless();
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://stellaops.io");
}
[Fact]
public void AddDsseSigning_ThrowsArgumentNullException_WhenServicesIsNull()
{
// Arrange
IServiceCollection? services = null;
// Act
var act = () => services!.AddDsseSigning();
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("services");
}
[Fact]
public void AddDsseSigningWithKms_ThrowsArgumentException_WhenKeyIdIsEmpty()
{
// Arrange
var services = new ServiceCollection();
// Act
var act = () => services.AddDsseSigningWithKms("");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("defaultKmsKeyId");
}
[Fact]
public void AddDsseSigning_RegistersTimeProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<TimeProvider>().Should().NotBeNull();
}
[Fact]
public void AddDsseSigning_DoesNotOverrideExistingTimeProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var customTimeProvider = new FakeTimeProvider();
services.AddSingleton<TimeProvider>(customTimeProvider);
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
var resolvedProvider = provider.GetRequiredService<TimeProvider>();
// Assert
resolvedProvider.Should().BeSameAs(customTimeProvider);
}
private sealed class FakeTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}

View File

@@ -15,6 +15,8 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.WebService\StellaOps.Signer.WebService.csproj" />