consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -16,11 +16,26 @@ Manage the attestation and proof chain infrastructure for StellaOps:
- Keep proof chain storage schema current with migrations.
## Key Components
### Attestor (transparency logging and verification)
- **StellaOps.Attestor**: Main attestation service and REST API endpoints
- **StellaOps.Attestor.Envelope**: DSSE envelope handling and serialization
- **StellaOps.Attestor.Types**: Core attestation models and schemas
- **StellaOps.Attestor.Verify**: Verification engine for signatures and Rekor proofs
- **__Libraries**: Shared attestation utilities and storage abstractions
- **__Libraries/StellaOps.Attestor.***: Shared attestation utilities and storage abstractions
### Signer (cryptographic signing -- trust domain co-located, Sprint 204)
- **StellaOps.Signer/StellaOps.Signer.Core**: Signing pipeline, predicate types, DSSE statement builder
- **StellaOps.Signer/StellaOps.Signer.Infrastructure**: Redis/cache/HTTP infrastructure for signing
- **StellaOps.Signer/StellaOps.Signer.WebService**: REST API (`/api/v1/signer/sign/dsse`)
- **__Libraries/StellaOps.Signer.KeyManagement**: Key rotation, trust anchors, HSM/KMS bindings (separate DB schema)
- **__Libraries/StellaOps.Signer.Keyless**: Fulcio/Sigstore keyless signing support
### Provenance (attestation library -- trust domain co-located, Sprint 204)
- **StellaOps.Provenance.Attestation**: SLSA/DSSE attestation generation library
- **StellaOps.Provenance.Attestation.Tool**: Forensic verification CLI tool
### Tests
- **__Tests**: Integration tests with Testcontainers for PostgreSQL
## Required Reading

View File

@@ -4,6 +4,9 @@
using Microsoft.Extensions.Logging;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Security;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
@@ -133,24 +136,21 @@ public sealed class DsseVerifier : IDsseVerifier
try
{
var signatureBytes = Convert.FromBase64String(sig.Sig);
if (VerifySignature(pae, signatureBytes, publicKeyPem))
var verification = VerifySignature(pae, signatureBytes, publicKeyPem);
if (verification.IsValid)
{
verifiedKeyIds.Add(sig.KeyId ?? "unknown");
_logger.LogDebug("DSSE signature verified for keyId: {KeyId}", sig.KeyId ?? "unknown");
}
else
{
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}");
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}:{verification.ReasonCode}");
}
}
catch (FormatException)
{
issues.Add($"signature_invalid_base64_{sig.KeyId ?? "unknown"}");
}
catch (CryptographicException ex)
{
issues.Add($"signature_crypto_error_{sig.KeyId ?? "unknown"}: {ex.Message}");
}
}
// Compute payload hash for result
@@ -236,49 +236,164 @@ public sealed class DsseVerifier : IDsseVerifier
/// <summary>
/// Verifies a signature against PAE using the provided public key.
/// Supports ECDSA P-256 and RSA keys.
/// Supports ECDSA, RSA, and Ed25519 keys.
/// </summary>
private bool VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
private SignatureVerificationResult VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
{
if (!TryExtractPublicKeyDer(publicKeyPem, out var publicKeyDer))
{
return SignatureVerificationResult.Invalid("invalid_public_key_material");
}
if (TryVerifyWithEcdsa(pae, signature, publicKeyDer, out var ecdsaResult))
{
return ecdsaResult;
}
if (TryVerifyWithRsa(pae, signature, publicKeyDer, out var rsaResult))
{
return rsaResult;
}
if (TryVerifyWithEd25519(pae, signature, publicKeyDer, out var ed25519Result))
{
return ed25519Result;
}
return SignatureVerificationResult.Invalid("unsupported_public_key_type");
}
private static bool TryVerifyWithEcdsa(
byte[] pae,
byte[] signature,
byte[] publicKeyDer,
out SignatureVerificationResult result)
{
// Try ECDSA first (most common for Sigstore/Fulcio)
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(publicKeyPem);
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
ecdsa.ImportSubjectPublicKeyInfo(publicKeyDer, out _);
var isValid = ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
result = isValid
? SignatureVerificationResult.Valid
: SignatureVerificationResult.Invalid("signature_mismatch");
return true;
}
catch (CryptographicException)
{
// Not an ECDSA key, try RSA
result = SignatureVerificationResult.NotApplicable;
return false;
}
}
// Try RSA
private static bool TryVerifyWithRsa(
byte[] pae,
byte[] signature,
byte[] publicKeyDer,
out SignatureVerificationResult result)
{
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
rsa.ImportSubjectPublicKeyInfo(publicKeyDer, out _);
var isValid = rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
result = isValid
? SignatureVerificationResult.Valid
: SignatureVerificationResult.Invalid("signature_mismatch");
return true;
}
catch (CryptographicException)
{
// Not an RSA key either
}
// Try Ed25519 if available (.NET 9+)
try
{
// Ed25519 support via System.Security.Cryptography
// Note: Ed25519 verification requires different handling
// For now, we log and return false - can be extended later
_logger.LogDebug("Ed25519 signature verification not yet implemented");
result = SignatureVerificationResult.NotApplicable;
return false;
}
catch
}
private static bool TryVerifyWithEd25519(
byte[] pae,
byte[] signature,
byte[] publicKeyDer,
out SignatureVerificationResult result)
{
try
{
// Ed25519 not available
var key = PublicKeyFactory.CreateKey(publicKeyDer);
if (key is not Ed25519PublicKeyParameters ed25519PublicKey)
{
result = SignatureVerificationResult.NotApplicable;
return false;
}
var verifier = new Ed25519Signer();
verifier.Init(false, ed25519PublicKey);
verifier.BlockUpdate(pae, 0, pae.Length);
var isValid = verifier.VerifySignature(signature);
result = isValid
? SignatureVerificationResult.Valid
: SignatureVerificationResult.Invalid("signature_mismatch");
return true;
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
{
result = SignatureVerificationResult.Invalid("invalid_public_key_material");
return true;
}
}
private static bool TryExtractPublicKeyDer(string publicKeyPem, out byte[] publicKeyDer)
{
publicKeyDer = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(publicKeyPem))
{
return false;
}
return false;
var beginMarker = "-----BEGIN PUBLIC KEY-----";
var endMarker = "-----END PUBLIC KEY-----";
var beginIndex = publicKeyPem.IndexOf(beginMarker, StringComparison.Ordinal);
var endIndex = publicKeyPem.IndexOf(endMarker, StringComparison.Ordinal);
if (beginIndex < 0 || endIndex <= beginIndex)
{
return false;
}
var bodyStart = beginIndex + beginMarker.Length;
var body = publicKeyPem[bodyStart..endIndex];
var normalized = new string(body.Where(static ch => !char.IsWhiteSpace(ch)).ToArray());
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
try
{
publicKeyDer = Convert.FromBase64String(normalized);
return publicKeyDer.Length > 0;
}
catch (FormatException)
{
return false;
}
}
private readonly struct SignatureVerificationResult
{
public static SignatureVerificationResult Valid => new(true, "none");
public static SignatureVerificationResult NotApplicable => new(false, "not_applicable");
public bool IsValid { get; }
public string ReasonCode { get; }
private SignatureVerificationResult(bool isValid, string reasonCode)
{
IsValid = isValid;
ReasonCode = reasonCode;
}
public static SignatureVerificationResult Invalid(string reasonCode) => new(false, reasonCode);
}
/// <summary>

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| ATTESTOR-225-001 | DOING | Sprint 225: implement Ed25519 DSSE verification with deterministic failure reasons and vectors. |
| AUDIT-0043-M | DONE | Revalidated maintainability for StellaOps.Attestation (2026-01-06). |
| AUDIT-0043-T | DONE | Revalidated test coverage for StellaOps.Attestation (2026-01-06). |
| AUDIT-0043-A | TODO | Open findings from revalidation (canonical JSON for DSSE payloads). |

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="..\..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| ATTESTOR-225-002 | DOING | Sprint 225 endpoint tests for trusted/revoked/unknown key scenarios. |
| ATTESTOR-225-003 | DOING | Sprint 225 tenant isolation and claim-derived tenant tests. |
| ATTESTOR-225-004 | DOING | Sprint 225 verdict-by-hash retrieval tests with authorization checks. |
| AUDIT-0066-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0066-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0066-A | DONE | Waived (test project; revalidated 2026-01-06). |

View File

@@ -11,6 +11,7 @@ using OpenTelemetry.Trace;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using StellaOps.Attestation;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
@@ -130,6 +131,8 @@ internal static class AttestorWebServiceComposition
builder.Services.AddOptions<AttestorWebServiceFeatures>()
.Bind(builder.Configuration.GetSection($"{configurationSection}:features"))
.ValidateOnStart();
builder.Services.AddOptions<VerdictAuthorityRosterOptions>()
.Bind(builder.Configuration.GetSection($"{configurationSection}:verdictTrust"));
var featureOptions = builder.Configuration.GetSection($"{configurationSection}:features")
.Get<AttestorWebServiceFeatures>() ?? new AttestorWebServiceFeatures();
@@ -141,6 +144,7 @@ internal static class AttestorWebServiceComposition
manager.FeatureProviders.Add(new AttestorWebServiceControllerFeatureProvider(featureOptions));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSingleton<IDsseVerifier, DsseVerifier>();
builder.Services.AddAttestorInfrastructure();
builder.Services.AddProofChainServices();

View File

@@ -98,3 +98,27 @@ public sealed class VerdictAttestationResponseDto
[JsonPropertyName("createdAt")]
public string CreatedAt { get; init; } = string.Empty;
}
/// <summary>
/// Response for verdict lookup by deterministic hash.
/// </summary>
public sealed class VerdictLookupResponseDto
{
[JsonPropertyName("verdictId")]
public string VerdictId { get; init; } = string.Empty;
[JsonPropertyName("attestationUri")]
public string AttestationUri { get; init; } = string.Empty;
[JsonPropertyName("envelope")]
public string Envelope { get; init; } = string.Empty;
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("createdAt")]
public string CreatedAt { get; init; } = string.Empty;
[JsonPropertyName("tenantId")]
public string TenantId { get; init; } = string.Empty;
}

View File

@@ -5,13 +5,17 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.Attestor.WebService.Options;
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -31,21 +35,28 @@ public class VerdictController : ControllerBase
{
private readonly IAttestationSigningService _signingService;
private readonly ILogger<VerdictController> _logger;
private readonly IDsseVerifier _dsseVerifier;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly AttestorWebServiceFeatures _features;
private readonly VerdictAuthorityRosterOptions _verdictRosterOptions;
private readonly TimeProvider _timeProvider;
private static readonly ConcurrentDictionary<string, CachedVerdictRecord> VerdictCache = new(StringComparer.Ordinal);
public VerdictController(
IAttestationSigningService signingService,
ILogger<VerdictController> logger,
IDsseVerifier dsseVerifier,
IHttpClientFactory? httpClientFactory = null,
IOptions<AttestorWebServiceFeatures>? features = null,
IOptions<VerdictAuthorityRosterOptions>? verdictRosterOptions = null,
TimeProvider? timeProvider = null)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
_httpClientFactory = httpClientFactory;
_features = features?.Value ?? new AttestorWebServiceFeatures();
_verdictRosterOptions = verdictRosterOptions?.Value ?? new VerdictAuthorityRosterOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
@@ -75,6 +86,14 @@ public class VerdictController : ControllerBase
"Creating verdict attestation for subject {SubjectName}",
request.Subject.Name);
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
if (tenantResolutionResult.Error is not null)
{
return tenantResolutionResult.Error;
}
var tenantId = tenantResolutionResult.TenantId!;
// Validate request
if (string.IsNullOrWhiteSpace(request.PredicateType))
{
@@ -114,9 +133,17 @@ public class VerdictController : ControllerBase
var predicateBase64 = Convert.ToBase64String(predicateBytes);
// Create signing request
var requestedKeyId = string.IsNullOrWhiteSpace(request.KeyId) ? "default" : request.KeyId.Trim();
var rosterResolution = ResolveRosterEntry(requestedKeyId);
if (rosterResolution.Error is not null)
{
return rosterResolution.Error;
}
var rosterEntry = rosterResolution.Entry!;
var signingRequest = new AttestationSignRequest
{
KeyId = request.KeyId ?? "default",
KeyId = requestedKeyId,
PayloadType = request.PredicateType,
PayloadBase64 = predicateBase64
};
@@ -127,7 +154,7 @@ public class VerdictController : ControllerBase
CallerSubject = "system",
CallerAudience = "policy-engine",
CallerClientId = "policy-engine-verdict-attestor",
CallerTenant = "default" // TODO: Extract from auth context
CallerTenant = tenantId
};
// Sign the predicate
@@ -137,12 +164,37 @@ public class VerdictController : ControllerBase
var envelope = signResult.Bundle.Dsse;
var envelopeJson = SerializeEnvelope(envelope, signResult.KeyId);
if (!string.Equals(signResult.KeyId, rosterEntry.KeyId, StringComparison.Ordinal))
{
return StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is not trusted by roster.",
detail: $"Signed key '{signResult.KeyId}' does not match roster key '{rosterEntry.KeyId}'.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_mismatch"));
}
var signatureVerification = await _dsseVerifier.VerifyAsync(envelopeJson, rosterEntry.PublicKeyPem, ct).ConfigureAwait(false);
if (!signatureVerification.IsValid)
{
return StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Verdict signature is untrusted.",
detail: "Signed verdict DSSE envelope failed authority roster verification.",
status: StatusCodes.Status403Forbidden,
code: "authority_signature_untrusted",
issues: signatureVerification.Issues.ToArray()));
}
// Rekor log index (not implemented in minimal handler)
long? rekorLogIndex = null;
// Store in Evidence Locker (via HTTP call)
await StoreVerdictInEvidenceLockerAsync(
verdictId,
tenantId,
request.Subject.Name,
envelopeJson,
signResult,
@@ -158,15 +210,13 @@ public class VerdictController : ControllerBase
KeyId = signResult.KeyId ?? request.KeyId ?? "default",
CreatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
VerdictCache[verdictId] = CachedVerdictRecord.From(response, tenantId);
_logger.LogInformation(
"Verdict attestation created successfully: {VerdictId}",
verdictId);
return CreatedAtRoute(
routeName: null, // No route name needed for external link
routeValues: null,
value: response);
return Created(attestationUri, response);
}
catch (Exception ex)
{
@@ -186,6 +236,60 @@ public class VerdictController : ControllerBase
}
}
/// <summary>
/// Retrieves a verdict attestation by deterministic verdict hash.
/// </summary>
[HttpGet("~/api/v1/verdicts/{verdictId}")]
[Authorize("attestor:read")]
[EnableRateLimiting("attestor-reads")]
[ProducesResponseType(typeof(VerdictLookupResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VerdictLookupResponseDto>> GetVerdictByHashAsync(
[FromRoute] string verdictId,
CancellationToken ct = default)
{
if (!_features.VerdictsEnabled)
{
return NotImplementedResult();
}
if (string.IsNullOrWhiteSpace(verdictId))
{
return BadRequest(CreateProblem(
title: "Invalid verdict identifier.",
detail: "verdictId is required.",
status: StatusCodes.Status400BadRequest,
code: "invalid_verdict_id"));
}
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
if (tenantResolutionResult.Error is not null)
{
return tenantResolutionResult.Error;
}
var tenantId = tenantResolutionResult.TenantId!;
if (VerdictCache.TryGetValue(verdictId, out var cached) &&
string.Equals(cached.TenantId, tenantId, StringComparison.Ordinal))
{
return Ok(cached.ToLookupResponse(verdictId));
}
var lockerResult = await FetchVerdictFromEvidenceLockerAsync(verdictId, tenantId, ct).ConfigureAwait(false);
if (lockerResult is not null)
{
VerdictCache[verdictId] = CachedVerdictRecord.From(lockerResult);
return Ok(lockerResult);
}
return NotFound(CreateProblem(
title: "Verdict not found.",
detail: $"No verdict exists for hash '{verdictId}' in tenant '{tenantId}'.",
status: StatusCodes.Status404NotFound,
code: "verdict_not_found"));
}
/// <summary>
/// Computes a deterministic verdict ID from predicate content.
/// </summary>
@@ -227,6 +331,7 @@ public class VerdictController : ControllerBase
/// </summary>
private async Task StoreVerdictInEvidenceLockerAsync(
string verdictId,
string tenantId,
string findingId,
string envelopeJson,
AttestationSignResult signResult,
@@ -268,7 +373,7 @@ public class VerdictController : ControllerBase
var storeRequest = new
{
verdict_id = verdictId,
tenant_id = "default", // TODO: Extract from auth context (requires CallerTenant from SubmissionContext)
tenant_id = tenantId,
policy_run_id = policyRunId,
policy_id = policyId,
policy_version = policyVersion,
@@ -310,6 +415,220 @@ public class VerdictController : ControllerBase
}
}
private (string? TenantId, ActionResult? Error) ResolveTenantContext(ClaimsPrincipal principal, IHeaderDictionary headers)
{
var tenantId = principal.FindFirst("tenant_id")?.Value
?? principal.FindFirst("tenant")?.Value;
if (string.IsNullOrWhiteSpace(tenantId))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Tenant claim is required.",
detail: "Authenticated principal does not contain tenant_id or tenant claim.",
status: StatusCodes.Status403Forbidden,
code: "tenant_claim_missing")));
}
if (headers.TryGetValue("X-Tenant-Id", out var headerTenant) &&
headerTenant.Count > 0 &&
!string.Equals(headerTenant[0], tenantId, StringComparison.Ordinal))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Tenant mismatch detected.",
detail: "Tenant header does not match authenticated tenant claim.",
status: StatusCodes.Status403Forbidden,
code: "tenant_mismatch")));
}
return (tenantId, null);
}
private (VerdictAuthorityKeyOptions? Entry, ActionResult? Error) ResolveRosterEntry(string keyId)
{
if (_verdictRosterOptions.Keys.Count == 0)
{
return (null, StatusCode(
StatusCodes.Status503ServiceUnavailable,
CreateProblem(
title: "Authority roster is unavailable.",
detail: "attestor:verdictTrust:keys must include at least one trusted key.",
status: StatusCodes.Status503ServiceUnavailable,
code: "authority_roster_unavailable")));
}
var entry = _verdictRosterOptions.Keys
.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
if (entry is null)
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is not in authority roster.",
detail: $"Key '{keyId}' is not trusted for verdict creation.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_unknown")));
}
if (string.Equals(entry.Status, "revoked", StringComparison.OrdinalIgnoreCase))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is revoked.",
detail: $"Key '{entry.KeyId}' is revoked in authority roster.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_revoked")));
}
if (string.IsNullOrWhiteSpace(entry.PublicKeyPem))
{
return (null, StatusCode(
StatusCodes.Status500InternalServerError,
CreateProblem(
title: "Authority roster key is incomplete.",
detail: $"Key '{entry.KeyId}' is missing public key material.",
status: StatusCodes.Status500InternalServerError,
code: "authority_key_missing_public_key")));
}
return (entry, null);
}
private async Task<VerdictLookupResponseDto?> FetchVerdictFromEvidenceLockerAsync(
string verdictId,
string tenantId,
CancellationToken ct)
{
if (_httpClientFactory is null)
{
return null;
}
try
{
var client = _httpClientFactory.CreateClient("EvidenceLocker");
var response = await client.GetAsync($"/api/v1/verdicts/{Uri.EscapeDataString(verdictId)}", ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Evidence Locker verdict lookup failed for {VerdictId}: {StatusCode}",
verdictId,
response.StatusCode);
return null;
}
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
if (payload.ValueKind != JsonValueKind.Object)
{
return null;
}
var lockerTenant = GetOptionalString(payload, "tenant_id", "tenantId");
if (!string.IsNullOrWhiteSpace(lockerTenant) &&
!string.Equals(lockerTenant, tenantId, StringComparison.Ordinal))
{
return null;
}
var envelope = ExtractEnvelope(payload);
if (string.IsNullOrWhiteSpace(envelope))
{
return null;
}
var keyId = GetOptionalString(payload, "key_id", "keyId") ?? "unknown";
var createdAt = GetOptionalString(payload, "evaluated_at", "created_at", "createdAt")
?? _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
return new VerdictLookupResponseDto
{
VerdictId = verdictId,
AttestationUri = $"/api/v1/verdicts/{verdictId}",
Envelope = envelope,
KeyId = keyId,
CreatedAt = createdAt,
TenantId = tenantId
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Evidence Locker verdict lookup failed for {VerdictId}", verdictId);
return null;
}
}
private static string? ExtractEnvelope(JsonElement payload)
{
if (!payload.TryGetProperty("envelope", out var envelopeElement))
{
return null;
}
if (envelopeElement.ValueKind == JsonValueKind.String)
{
return envelopeElement.GetString();
}
if (envelopeElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
var envelopeJson = envelopeElement.GetRawText();
return Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
}
return null;
}
private static string? GetOptionalString(JsonElement payload, params string[] candidates)
{
foreach (var candidate in candidates)
{
if (payload.TryGetProperty(candidate, out var value) &&
value.ValueKind == JsonValueKind.String)
{
var text = value.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
return text;
}
}
}
return null;
}
private static ProblemDetails CreateProblem(
string title,
string detail,
int status,
string code,
string[]? issues = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = status
};
problem.Extensions["code"] = code;
if (issues is not null && issues.Length > 0)
{
problem.Extensions["issues"] = issues;
}
return problem;
}
/// <summary>
/// Extracts verdict metadata from predicate JSON.
/// </summary>
@@ -418,4 +737,28 @@ public class VerdictController : ControllerBase
StatusCode = StatusCodes.Status501NotImplemented
};
}
private sealed record CachedVerdictRecord(
string TenantId,
string Envelope,
string KeyId,
string CreatedAt)
{
public static CachedVerdictRecord From(VerdictAttestationResponseDto response, string tenantId)
=> new(tenantId, response.Envelope, response.KeyId, response.CreatedAt);
public static CachedVerdictRecord From(VerdictLookupResponseDto response)
=> new(response.TenantId, response.Envelope, response.KeyId, response.CreatedAt);
public VerdictLookupResponseDto ToLookupResponse(string verdictId)
=> new()
{
VerdictId = verdictId,
AttestationUri = $"/api/v1/verdicts/{verdictId}",
Envelope = Envelope,
KeyId = KeyId,
CreatedAt = CreatedAt,
TenantId = TenantId
};
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Attestor.WebService.Options;
public sealed class VerdictAuthorityRosterOptions
{
public List<VerdictAuthorityKeyOptions> Keys { get; set; } = [];
}
public sealed class VerdictAuthorityKeyOptions
{
public string KeyId { get; set; } = string.Empty;
public string Status { get; set; } = "trusted";
public string PublicKeyPem { get; set; } = string.Empty;
}

View File

@@ -18,6 +18,7 @@
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| ATTESTOR-225-002 | DOING | Sprint 225: enforce roster-based trust verification before verdict append. |
| ATTESTOR-225-003 | DOING | Sprint 225: resolve tenant from authenticated claims and block spoofing. |
| ATTESTOR-225-004 | DOING | Sprint 225: implement verdict-by-hash retrieval and tenant-scoped access checks. |
| AUDIT-0072-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0072-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0072-A | DONE | Applied 2026-01-13 (feature gating, correlation ID provider, proof chain/verification summary updates, tests). |

View File

@@ -0,0 +1,138 @@
using StellaOps.Provenance.Attestation;
using System.Globalization;
using System.Text.Json;
return await ToolEntrypoint.RunAsync(args, Console.Out, Console.Error, TimeProvider.System);
internal static class ToolEntrypoint
{
private const int ExitInvalid = 1;
private const int ExitUnverified = 2;
public static async Task<int> RunAsync(string[] args, TextWriter stdout, TextWriter stderr, TimeProvider timeProvider)
{
var options = Parse(args);
if (!options.Valid)
{
return Usage(stderr);
}
byte[] payload;
try
{
payload = options.PayloadPath == "-"
? await ReadAllAsync(Console.OpenStandardInput())
: await File.ReadAllBytesAsync(options.PayloadPath!);
}
catch (Exception ex)
{
await stderr.WriteLineAsync($"read error: {ex.Message}");
return ExitInvalid;
}
byte[] signature;
byte[] key;
try
{
signature = Hex.FromHex(options.SignatureHex!);
key = Hex.FromHex(options.KeyHex!);
}
catch (Exception ex)
{
await stderr.WriteLineAsync($"hex parse error: {ex.Message}");
return ExitInvalid;
}
var signRequest = new SignRequest(payload, options.ContentType!, RequiredClaims: new[] { "predicateType" });
var signResult = new SignResult(signature, options.KeyId!, options.SignedAt ?? DateTimeOffset.MinValue, null);
var verifier = new HmacVerifier(new InMemoryKeyProvider(options.KeyId!, key, options.NotAfter), timeProvider, options.MaxSkew);
var verifyResult = await verifier.VerifyAsync(signRequest, signResult);
var json = JsonSerializer.Serialize(new
{
valid = verifyResult.IsValid,
reason = verifyResult.Reason,
verifiedAt = verifyResult.VerifiedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
keyId = options.KeyId,
contentType = options.ContentType
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false });
await stdout.WriteLineAsync(json);
return verifyResult.IsValid ? 0 : ExitUnverified;
}
private static async Task<byte[]> ReadAllAsync(Stream stream)
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
private static int Usage(TextWriter stderr)
{
stderr.WriteLine("Usage: stella-forensic-verify --payload <file|-> --signature-hex <hex> --key-hex <hex> [--key-id <id>] [--content-type <ct>] [--signed-at <ISO>] [--not-after <ISO>] [--max-skew-minutes <int>]");
stderr.WriteLine("Exit codes: 0 valid, 2 invalid signature/time, 1 bad args");
return ExitInvalid;
}
private static ParsedOptions Parse(string[] args)
{
string? GetArg(string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return null;
}
var payload = GetArg("--payload");
var sig = GetArg("--signature-hex");
var key = GetArg("--key-hex");
if (payload is null || sig is null || key is null)
{
return ParsedOptions.Invalid;
}
DateTimeOffset? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTimeOffset.Parse(value!, null, System.Globalization.DateTimeStyles.RoundtripKind);
}
TimeSpan ParseSkew(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return TimeSpan.FromMinutes(5);
return TimeSpan.FromMinutes(double.Parse(value!, System.Globalization.CultureInfo.InvariantCulture));
}
return new ParsedOptions(
Valid: true,
PayloadPath: payload,
SignatureHex: sig,
KeyHex: key,
KeyId: GetArg("--key-id") ?? "hmac",
ContentType: GetArg("--content-type") ?? "application/octet-stream",
SignedAt: ParseDate(GetArg("--signed-at")),
NotAfter: ParseDate(GetArg("--not-after")),
MaxSkew: ParseSkew(GetArg("--max-skew-minutes"))
);
}
private sealed record ParsedOptions(
bool Valid,
string? PayloadPath = null,
string? SignatureHex = null,
string? KeyHex = null,
string? KeyId = null,
string? ContentType = null,
DateTimeOffset? SignedAt = null,
DateTimeOffset? NotAfter = null,
TimeSpan MaxSkew = default)
{
public static readonly ParsedOptions Invalid = new(false);
}
}

View File

@@ -0,0 +1,34 @@
# stella-forensic-verify (preview)
Minimal .NET 10 global tool for offline verification of provenance payloads signed with an HMAC key. No network access; deterministic JSON output.
## Usage
```
stella-forensic-verify \
--payload payload.bin # or '-' to read stdin
--signature-hex DEADBEEF... # hex-encoded HMAC
--key-hex 001122... # hex-encoded HMAC key
[--key-id hmac] # optional key id
[--content-type application/octet-stream]
[--signed-at 2025-11-21T12:00:00Z]
[--not-after 2025-12-31T23:59:59Z]
[--max-skew-minutes 5]
```
Output (single line, deterministic field order):
```
{"valid":true,"reason":"verified","verifiedAt":"2025-11-22T12:00:00.0000000Z","keyId":"hmac","contentType":"application/octet-stream"}
```
## Exit codes
- 0: signature valid
- 2: signature/time invalid
- 1: bad arguments or hex parse failure
## Offline kit packaging (manual)
1. `dotnet pack src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj -c Release -o out/tools`
2. Copy the produced nupkg into the offline kit under `tools/`.
3. Install in air-gap host: `dotnet tool install --global --add-source tools stella-forensic-verify --version <pkg-version>`.
4. Document expected SHA256 of the nupkg alongside the kit manifest.

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<PackAsTool>true</PackAsTool>
<ToolCommandName>stella-forensic-verify</ToolCommandName>
<PackageOutputPath>../../out/tools</PackageOutputPath>
<!-- Clear restore sources to use only explicit feeds (from deleted Directory.Build.props) -->
<RestoreSources>;;</RestoreSources>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Provenance.Attestation.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.Provenance.Attestation.Tool Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1,32 @@
# StellaOps Provenance & Attestation Guild Charter
## Mission
Provide shared libraries and tooling for generating, signing, and verifying provenance attestations (DSSE/SLSA) used by evidence bundles, exports, and timeline verification flows.
## Scope
- DSSE statement builders with Merkle and digest utilities.
- Signer/validator abstractions for KMS, cosign, offline keys.
- Provenance schema definitions reused across services and CLI.
- Verification harnesses for evidence locker and export center integrations.
## Collaboration
- Partner with Evidence Locker, Exporter, Orchestrator, and CLI guilds for integration.
- Coordinate with Security Guild on key management policies and rotation logs.
- Ensure docs in `docs/modules/provenance/guides/provenance-attestation.md` stay aligned with implementation.
## Definition of Done
- Libraries ship with deterministic serialization tests.
- Threat model reviewed before each release.
- Sample statements and verification scripts committed under `samples/provenance/`.
## Required Reading
- `docs/modules/provenance/guides/provenance-attestation.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,148 @@
using StellaOps.Cryptography;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Provenance.Attestation;
public sealed record BuildDefinition(
string BuildType,
IReadOnlyDictionary<string, string>? ExternalParameters = null,
IReadOnlyDictionary<string, string>? ResolvedDependencies = null);
public sealed record BuildMetadata(
string? BuildInvocationId,
DateTimeOffset? BuildStartedOn,
DateTimeOffset? BuildFinishedOn,
bool? Reproducible = null,
IReadOnlyDictionary<string, bool>? Completeness = null,
IReadOnlyDictionary<string, string>? Environment = null);
public static class CanonicalJson
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = null,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public static byte[] SerializeToUtf8Bytes<T>(T value)
{
var element = JsonSerializer.SerializeToElement(value, Options);
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
WriteCanonical(element, writer);
writer.Flush();
return stream.ToArray();
}
public static string SerializeToString<T>(T value) => Encoding.UTF8.GetString(SerializeToUtf8Bytes(value));
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonical(property.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonical(item, writer);
}
writer.WriteEndArray();
break;
default:
element.WriteTo(writer);
break;
}
}
}
public static class MerkleTree
{
public static byte[] ComputeRoot(ICryptoHash cryptoHash, IEnumerable<byte[]> leaves)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var leafList = leaves?.ToList() ?? throw new ArgumentNullException(nameof(leaves));
if (leafList.Count == 0) throw new ArgumentException("At least one leaf required", nameof(leaves));
var level = leafList.Select(data => NormalizeLeaf(cryptoHash, data)).ToList();
while (level.Count > 1)
{
var next = new List<byte[]>((level.Count + 1) / 2);
for (var i = 0; i < level.Count; i += 2)
{
var left = level[i];
var right = i + 1 < level.Count ? level[i + 1] : left;
var combined = new byte[left.Length + right.Length];
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
next.Add(cryptoHash.ComputeHashForPurpose(combined, HashPurpose.Merkle));
}
level = next;
}
return level[0];
}
private static byte[] NormalizeLeaf(ICryptoHash cryptoHash, byte[] data)
{
if (data.Length == 32) return data;
return cryptoHash.ComputeHashForPurpose(data, HashPurpose.Merkle);
}
}
public sealed record BuildStatement(
BuildDefinition BuildDefinition,
BuildMetadata BuildMetadata);
public static class BuildStatementFactory
{
public static BuildStatement Create(BuildDefinition definition, BuildMetadata metadata) => new(definition, metadata);
}
public static class BuildStatementDigest
{
public static byte[] ComputeHash(ICryptoHash cryptoHash, BuildStatement statement)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(statement);
var canonicalBytes = CanonicalJson.SerializeToUtf8Bytes(statement);
return cryptoHash.ComputeHashForPurpose(canonicalBytes, HashPurpose.Attestation);
}
public static string ComputeHashHex(ICryptoHash cryptoHash, BuildStatement statement)
{
return Convert.ToHexStringLower(ComputeHash(cryptoHash, statement));
}
public static byte[] ComputeMerkleRoot(ICryptoHash cryptoHash, IEnumerable<BuildStatement> statements)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(statements);
var leaves = statements.Select(s => ComputeHash(cryptoHash, s)).ToArray();
if (leaves.Length == 0)
{
throw new ArgumentException("At least one build statement required", nameof(statements));
}
return MerkleTree.ComputeRoot(cryptoHash, leaves);
}
public static string ComputeMerkleRootHex(ICryptoHash cryptoHash, IEnumerable<BuildStatement> statements)
{
return Convert.ToHexStringLower(ComputeMerkleRoot(cryptoHash, statements));
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace StellaOps.Provenance.Attestation;
public static class Hex
{
public static byte[] FromHex(string hex)
{
if (string.IsNullOrWhiteSpace(hex)) throw new ArgumentException("hex is required", nameof(hex));
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
if (hex.Length % 2 != 0) throw new FormatException("hex length must be even");
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
return bytes;
}
}

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
namespace StellaOps.Provenance.Attestation;
public sealed record PromotionPredicate(
string ImageDigest,
string SbomDigest,
string VexDigest,
string PromotionId,
string? RekorEntry = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public sealed record PromotionAttestation(
PromotionPredicate Predicate,
byte[] Payload,
SignResult Signature);
public static class PromotionAttestationBuilder
{
public const string PredicateType = "stella.ops/promotion@v1";
public const string ContentType = "application/vnd.stella.promotion+json";
public static byte[] CreateCanonicalJson(PromotionPredicate predicate)
{
if (predicate is null) throw new ArgumentNullException(nameof(predicate));
return CanonicalJson.SerializeToUtf8Bytes(predicate);
}
public static async Task<PromotionAttestation> BuildAsync(
PromotionPredicate predicate,
ISigner signer,
IReadOnlyDictionary<string, string>? claims = null,
CancellationToken cancellationToken = default)
{
if (predicate is null) throw new ArgumentNullException(nameof(predicate));
if (signer is null) throw new ArgumentNullException(nameof(signer));
var payload = CreateCanonicalJson(predicate);
// ensure predicate type claim is always present
var mergedClaims = claims is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(claims, StringComparer.Ordinal);
mergedClaims["predicateType"] = PredicateType;
var request = new SignRequest(
Payload: payload,
ContentType: ContentType,
Claims: mergedClaims,
RequiredClaims: new[] { "predicateType" });
var signature = await signer.SignAsync(request, cancellationToken).ConfigureAwait(false);
return new PromotionAttestation(predicate, payload, signature);
}
}

View File

@@ -0,0 +1,253 @@
using StellaOps.Cryptography;
namespace StellaOps.Provenance.Attestation;
public sealed record SignRequest(
byte[] Payload,
string ContentType,
IReadOnlyDictionary<string, string>? Claims = null,
IReadOnlyCollection<string>? RequiredClaims = null);
public sealed record SignResult(
byte[] Signature,
string KeyId,
DateTimeOffset SignedAt,
IReadOnlyDictionary<string, string>? Claims);
public interface IKeyProvider
{
string KeyId { get; }
byte[] KeyMaterial { get; }
DateTimeOffset? NotAfter { get; }
}
public interface IAuditSink
{
void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt);
void LogMissingClaim(string keyId, string claimName);
void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt);
}
public sealed class NullAuditSink : IAuditSink
{
public static readonly NullAuditSink Instance = new();
private NullAuditSink() { }
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt) { }
public void LogMissingClaim(string keyId, string claimName) { }
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt) { }
}
public sealed class HmacSigner : ISigner
{
private readonly IKeyProvider _keyProvider;
private readonly ICryptoHmac _cryptoHmac;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public HmacSigner(IKeyProvider keyProvider, ICryptoHmac cryptoHmac, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.RequiredClaims is not null)
{
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyProvider.KeyId, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
else if (request.Claims is null || request.Claims.Count == 0)
{
// allow empty claims for legacy rotation tests and non-DSSE payloads
// (predicateType enforcement happens at PromotionAttestationBuilder layer)
}
var signature = _cryptoHmac.ComputeHmacForPurpose(_keyProvider.KeyMaterial, request.Payload, HmacPurpose.Signing);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
return Task.FromResult(new SignResult(
Signature: signature,
KeyId: _keyProvider.KeyId,
SignedAt: signedAt,
Claims: request.Claims));
}
}
public interface ISigner
{
Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default);
}
public sealed class InMemoryKeyProvider : IKeyProvider
{
public string KeyId { get; }
public byte[] KeyMaterial { get; }
public DateTimeOffset? NotAfter { get; }
public InMemoryKeyProvider(string keyId, byte[] keyMaterial, DateTimeOffset? notAfter = null)
{
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
KeyMaterial = keyMaterial ?? throw new ArgumentNullException(nameof(keyMaterial));
NotAfter = notAfter;
}
}
public sealed class InMemoryAuditSink : IAuditSink
{
public List<(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)> Signed { get; } = new();
public List<(string keyId, string claim)> Missing { get; } = new();
public List<(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)> Rotations { get; } = new();
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)
=> Signed.Add((keyId, contentType, claims, signedAt));
public void LogMissingClaim(string keyId, string claimName)
=> Missing.Add((keyId, claimName));
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)
=> Rotations.Add((previousKeyId, nextKeyId, rotatedAt));
}
public sealed class RotatingKeyProvider : IKeyProvider
{
private readonly IReadOnlyList<IKeyProvider> _keys;
private readonly TimeProvider _timeProvider;
private readonly IAuditSink _audit;
private string _activeKeyId;
public RotatingKeyProvider(IEnumerable<IKeyProvider> keys, TimeProvider? timeProvider = null, IAuditSink? audit = null)
{
_keys = keys?.ToList() ?? throw new ArgumentNullException(nameof(keys));
if (_keys.Count == 0) throw new ArgumentException("At least one key is required", nameof(keys));
_timeProvider = timeProvider ?? TimeProvider.System;
_audit = audit ?? NullAuditSink.Instance;
_activeKeyId = _keys[0].KeyId;
}
private IKeyProvider ResolveActive()
{
var now = _timeProvider.GetUtcNow();
var next = _keys
.OrderByDescending(k => k.NotAfter ?? DateTimeOffset.MaxValue)
.First(k => !k.NotAfter.HasValue || k.NotAfter.Value >= now);
if (!string.Equals(next.KeyId, _activeKeyId, StringComparison.Ordinal))
{
_audit.LogKeyRotation(_activeKeyId, next.KeyId, now);
_activeKeyId = next.KeyId;
}
return next;
}
public string KeyId => ResolveActive().KeyId;
public byte[] KeyMaterial => ResolveActive().KeyMaterial;
public DateTimeOffset? NotAfter => ResolveActive().NotAfter;
}
public interface ICosignClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken);
}
public interface IKmsClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken);
}
public sealed class CosignSigner : ISigner
{
private readonly string _keyRef;
private readonly ICosignClient _client;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public CosignSigner(string keyRef, ICosignClient client, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_keyRef = string.IsNullOrWhiteSpace(keyRef) ? throw new ArgumentException("Key reference required", nameof(keyRef)) : keyRef;
_client = client ?? throw new ArgumentNullException(nameof(client));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
EnforceClaims(request);
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyRef, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyRef, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyRef, signedAt, request.Claims);
}
private void EnforceClaims(SignRequest request)
{
if (request.RequiredClaims is null)
{
return;
}
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyRef, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
}
public sealed class KmsSigner : ISigner
{
private readonly IKmsClient _client;
private readonly IKeyProvider _keyProvider;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public KmsSigner(IKmsClient client, IKeyProvider keyProvider, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.RequiredClaims is not null)
{
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyProvider.KeyId, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyProvider.KeyId, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyProvider.KeyId, signedAt, request.Claims);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.Provenance.Attestation Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Provenance/StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,103 @@
using StellaOps.Cryptography;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Provenance.Attestation;
public sealed record VerificationResult(bool IsValid, string Reason, DateTimeOffset VerifiedAt);
public interface IVerifier
{
Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default);
}
public sealed class HmacVerifier : IVerifier
{
private readonly IKeyProvider _keyProvider;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _maxClockSkew;
public HmacVerifier(IKeyProvider keyProvider, TimeProvider? timeProvider = null, TimeSpan? maxClockSkew = null)
{
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
_maxClockSkew = maxClockSkew ?? TimeSpan.FromMinutes(5);
}
public Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (signature is null) throw new ArgumentNullException(nameof(signature));
using var hmac = new HMACSHA256(_keyProvider.KeyMaterial);
var expected = hmac.ComputeHash(request.Payload);
var ok = CryptographicOperations.FixedTimeEquals(expected, signature.Signature) &&
string.Equals(_keyProvider.KeyId, signature.KeyId, StringComparison.Ordinal);
// enforce not-after validity and basic clock skew checks for offline verification
var now = _timeProvider.GetUtcNow();
if (_keyProvider.NotAfter.HasValue && signature.SignedAt > _keyProvider.NotAfter.Value)
{
ok = false;
}
if (signature.SignedAt - now > _maxClockSkew)
{
ok = false;
}
var result = new VerificationResult(
IsValid: ok,
Reason: ok ? "verified" : "signature or time invalid",
VerifiedAt: _timeProvider.GetUtcNow());
return Task.FromResult(result);
}
}
public static class MerkleRootVerifier
{
public static VerificationResult VerifyRoot(ICryptoHash cryptoHash, IEnumerable<byte[]> leaves, byte[] expectedRoot, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var provider = timeProvider ?? TimeProvider.System;
if (leaves is null) throw new ArgumentNullException(nameof(leaves));
if (expectedRoot is null) throw new ArgumentNullException(nameof(expectedRoot));
var leafList = leaves.ToList();
var computed = MerkleTree.ComputeRoot(cryptoHash, leafList);
var ok = CryptographicOperations.FixedTimeEquals(computed, expectedRoot);
return new VerificationResult(ok, ok ? "verified" : "merkle root mismatch", provider.GetUtcNow());
}
}
public static class ChainOfCustodyVerifier
{
/// <summary>
/// Verifies a simple chain-of-custody where each hop is hashed onto the previous aggregate.
/// head = Hash(hopN || ... || hop1) using the active compliance profile's attestation algorithm.
/// </summary>
public static VerificationResult Verify(ICryptoHash cryptoHash, IEnumerable<byte[]> hops, byte[] expectedHead, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var provider = timeProvider ?? TimeProvider.System;
if (hops is null) throw new ArgumentNullException(nameof(hops));
if (expectedHead is null) throw new ArgumentNullException(nameof(expectedHead));
var list = hops.ToList();
if (list.Count == 0)
{
return new VerificationResult(false, "no hops", provider.GetUtcNow());
}
byte[] aggregate = Array.Empty<byte>();
foreach (var hop in list)
{
aggregate = cryptoHash.ComputeHashForPurpose(aggregate.Concat(hop).ToArray(), HashPurpose.Attestation);
}
var ok = CryptographicOperations.FixedTimeEquals(aggregate, expectedHead);
return new VerificationResult(ok, ok ? "verified" : "chain mismatch", provider.GetUtcNow());
}
}

View File

@@ -0,0 +1,32 @@
# Signer Guild
## Mission
Operate the StellaOps Signer service: authenticate trusted callers, enforce proofofentitlement and release integrity policy, and mint verifiable DSSE bundles (keyless or KMS-backed) for downstream attestation.
## Teams On Call
- Team 11 (Signer API)
- Team 12 (Signer Reliability & Quotas)
## Operating Principles
- Accept requests only with Authority-issued OpToks plus DPoP or mTLS sender binding; reject unsigned/cross-tenant traffic.
- Treat PoE claims as hard gates for quota, version windows, and license validity; cache results deterministically with bounded TTLs.
- Verify scanner image release signatures via OCI Referrers before signing; fail closed on ambiguity.
- Keep the hot path stateless and deterministic; persist audit trails with structured logging, metrics, and correlation IDs.
- Update `TASKS.md`, architecture notes, and tests whenever behaviour or contracts evolve.
## Key Directories
- `src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/` — Minimal API host and HTTP surface (to be scaffolded).
- `src/Signer/StellaOps.Signer/StellaOps.Signer.Core/` — Domain contracts, signing pipeline, quota enforcement (to be scaffolded).
- `src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/` — External clients (Authority, Licensing, Fulcio/KMS, OCI) and persistence (to be scaffolded).
- `src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/` — Unit/integration test suites (to be scaffolded).
## Required Reading
- `docs/modules/signer/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// CeremonyAuditEvents.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-008
// Description: Audit event definitions for dual-control ceremonies.
// -----------------------------------------------------------------------------
using System;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Audit event types for ceremonies.
/// </summary>
public static class CeremonyAuditEvents
{
/// <summary>
/// Ceremony was created.
/// </summary>
public const string Initiated = "signer.ceremony.initiated";
/// <summary>
/// Approval was submitted.
/// </summary>
public const string Approved = "signer.ceremony.approved";
/// <summary>
/// Threshold was reached.
/// </summary>
public const string ThresholdReached = "signer.ceremony.threshold_reached";
/// <summary>
/// Operation was executed.
/// </summary>
public const string Executed = "signer.ceremony.executed";
/// <summary>
/// Ceremony expired.
/// </summary>
public const string Expired = "signer.ceremony.expired";
/// <summary>
/// Ceremony was cancelled.
/// </summary>
public const string Cancelled = "signer.ceremony.cancelled";
/// <summary>
/// Approval was rejected (invalid signature, unauthorized, etc.).
/// </summary>
public const string ApprovalRejected = "signer.ceremony.approval_rejected";
}
/// <summary>
/// Base audit event for ceremonies.
/// </summary>
public abstract record CeremonyAuditEvent
{
/// <summary>
/// Event type.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Ceremony ID.
/// </summary>
public required Guid CeremonyId { get; init; }
/// <summary>
/// Operation type.
/// </summary>
public required CeremonyOperationType OperationType { get; init; }
/// <summary>
/// Event timestamp (UTC).
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Actor identity.
/// </summary>
public required string Actor { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Request trace ID.
/// </summary>
public string? TraceId { get; init; }
}
/// <summary>
/// Audit event for ceremony initiation.
/// </summary>
public sealed record CeremonyInitiatedEvent : CeremonyAuditEvent
{
/// <summary>
/// Threshold required.
/// </summary>
public required int ThresholdRequired { get; init; }
/// <summary>
/// Expiration time.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Operation description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Audit event for ceremony approval.
/// </summary>
public sealed record CeremonyApprovedEvent : CeremonyAuditEvent
{
/// <summary>
/// Approver identity.
/// </summary>
public required string Approver { get; init; }
/// <summary>
/// Current approval count.
/// </summary>
public required int ApprovalCount { get; init; }
/// <summary>
/// Required threshold.
/// </summary>
public required int ThresholdRequired { get; init; }
/// <summary>
/// Approval reason.
/// </summary>
public string? ApprovalReason { get; init; }
/// <summary>
/// Whether threshold was reached with this approval.
/// </summary>
public required bool ThresholdReached { get; init; }
}
/// <summary>
/// Audit event for ceremony execution.
/// </summary>
public sealed record CeremonyExecutedEvent : CeremonyAuditEvent
{
/// <summary>
/// Executor identity.
/// </summary>
public required string Executor { get; init; }
/// <summary>
/// Total approvals.
/// </summary>
public required int TotalApprovals { get; init; }
/// <summary>
/// Execution result.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Result payload (key ID, etc.).
/// </summary>
public string? ResultPayload { get; init; }
}
/// <summary>
/// Audit event for ceremony expiration.
/// </summary>
public sealed record CeremonyExpiredEvent : CeremonyAuditEvent
{
/// <summary>
/// Approvals received before expiration.
/// </summary>
public required int ApprovalsReceived { get; init; }
/// <summary>
/// Threshold that was required.
/// </summary>
public required int ThresholdRequired { get; init; }
}
/// <summary>
/// Audit event for ceremony cancellation.
/// </summary>
public sealed record CeremonyCancelledEvent : CeremonyAuditEvent
{
/// <summary>
/// Cancellation reason.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// State at time of cancellation.
/// </summary>
public required CeremonyState StateAtCancellation { get; init; }
/// <summary>
/// Approvals received before cancellation.
/// </summary>
public required int ApprovalsReceived { get; init; }
}
/// <summary>
/// Audit event for rejected approval.
/// </summary>
public sealed record CeremonyApprovalRejectedEvent : CeremonyAuditEvent
{
/// <summary>
/// Attempted approver.
/// </summary>
public required string AttemptedApprover { get; init; }
/// <summary>
/// Rejection reason.
/// </summary>
public required string RejectionReason { get; init; }
/// <summary>
/// Error code.
/// </summary>
public required CeremonyErrorCode ErrorCode { get; init; }
}

View File

@@ -0,0 +1,380 @@
// -----------------------------------------------------------------------------
// CeremonyModels.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-001, DUAL-003, DUAL-004
// Description: Models for M-of-N dual-control signing ceremonies.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// State of a signing ceremony.
/// </summary>
public enum CeremonyState
{
/// <summary>
/// Ceremony created, awaiting approvals.
/// </summary>
Pending,
/// <summary>
/// Some approvals received, but threshold not yet reached.
/// </summary>
PartiallyApproved,
/// <summary>
/// Threshold reached, operation approved for execution.
/// </summary>
Approved,
/// <summary>
/// Operation executed successfully.
/// </summary>
Executed,
/// <summary>
/// Ceremony expired before threshold was reached.
/// </summary>
Expired,
/// <summary>
/// Ceremony cancelled by initiator or admin.
/// </summary>
Cancelled
}
/// <summary>
/// Type of key operation requiring ceremony approval.
/// </summary>
public enum CeremonyOperationType
{
/// <summary>
/// Generate a new signing key.
/// </summary>
KeyGeneration,
/// <summary>
/// Rotate an existing key.
/// </summary>
KeyRotation,
/// <summary>
/// Revoke a key.
/// </summary>
KeyRevocation,
/// <summary>
/// Export a key (for escrow or backup).
/// </summary>
KeyExport,
/// <summary>
/// Import a key from escrow or backup.
/// </summary>
KeyImport,
/// <summary>
/// Emergency key recovery.
/// </summary>
KeyRecovery
}
/// <summary>
/// A signing ceremony requiring M-of-N approvals.
/// </summary>
public sealed record Ceremony
{
/// <summary>
/// Unique ceremony identifier.
/// </summary>
public required Guid CeremonyId { get; init; }
/// <summary>
/// Type of operation being approved.
/// </summary>
public required CeremonyOperationType OperationType { get; init; }
/// <summary>
/// Operation-specific payload (key ID, parameters, etc.).
/// </summary>
public required CeremonyOperationPayload Payload { get; init; }
/// <summary>
/// Number of approvals required (M in M-of-N).
/// </summary>
public required int ThresholdRequired { get; init; }
/// <summary>
/// Current number of approvals received.
/// </summary>
public required int ThresholdReached { get; init; }
/// <summary>
/// Current ceremony state.
/// </summary>
public required CeremonyState State { get; init; }
/// <summary>
/// Identity of the ceremony initiator.
/// </summary>
public required string InitiatedBy { get; init; }
/// <summary>
/// When the ceremony was initiated (UTC).
/// </summary>
public required DateTimeOffset InitiatedAt { get; init; }
/// <summary>
/// When the ceremony expires (UTC).
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// When the operation was executed (UTC), if executed.
/// </summary>
public DateTimeOffset? ExecutedAt { get; init; }
/// <summary>
/// Collected approvals.
/// </summary>
public IReadOnlyList<CeremonyApproval> Approvals { get; init; } = [];
/// <summary>
/// Human-readable description of the ceremony.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Tenant ID if multi-tenant.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Operation-specific payload for a ceremony.
/// </summary>
public sealed record CeremonyOperationPayload
{
/// <summary>
/// Key identifier (for rotation, revocation, export).
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Key algorithm (for generation).
/// </summary>
public string? Algorithm { get; init; }
/// <summary>
/// Key size in bits (for generation).
/// </summary>
public int? KeySize { get; init; }
/// <summary>
/// Key usage constraints.
/// </summary>
public IReadOnlyList<string>? KeyUsages { get; init; }
/// <summary>
/// Reason for the operation.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// An approval for a ceremony.
/// </summary>
public sealed record CeremonyApproval
{
/// <summary>
/// Unique approval identifier.
/// </summary>
public required Guid ApprovalId { get; init; }
/// <summary>
/// Ceremony being approved.
/// </summary>
public required Guid CeremonyId { get; init; }
/// <summary>
/// Identity of the approver.
/// </summary>
public required string ApproverIdentity { get; init; }
/// <summary>
/// When the approval was given (UTC).
/// </summary>
public required DateTimeOffset ApprovedAt { get; init; }
/// <summary>
/// Cryptographic signature over the ceremony details.
/// </summary>
public required byte[] ApprovalSignature { get; init; }
/// <summary>
/// Optional reason or comment for approval.
/// </summary>
public string? ApprovalReason { get; init; }
/// <summary>
/// Key ID used for signing the approval.
/// </summary>
public string? SigningKeyId { get; init; }
/// <summary>
/// Signature algorithm used.
/// </summary>
public string? SignatureAlgorithm { get; init; }
}
/// <summary>
/// Request to create a new ceremony.
/// </summary>
public sealed record CreateCeremonyRequest
{
/// <summary>
/// Type of operation.
/// </summary>
public required CeremonyOperationType OperationType { get; init; }
/// <summary>
/// Operation payload.
/// </summary>
public required CeremonyOperationPayload Payload { get; init; }
/// <summary>
/// Override threshold (uses config default if null).
/// </summary>
public int? ThresholdOverride { get; init; }
/// <summary>
/// Override expiration minutes (uses config default if null).
/// </summary>
public int? ExpirationMinutesOverride { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Tenant ID if multi-tenant.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Request to approve a ceremony.
/// </summary>
public sealed record ApproveCeremonyRequest
{
/// <summary>
/// Ceremony to approve.
/// </summary>
public required Guid CeremonyId { get; init; }
/// <summary>
/// Cryptographic signature over ceremony details.
/// </summary>
public required byte[] ApprovalSignature { get; init; }
/// <summary>
/// Optional reason for approval.
/// </summary>
public string? ApprovalReason { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
public string? SigningKeyId { get; init; }
/// <summary>
/// Signature algorithm.
/// </summary>
public string? SignatureAlgorithm { get; init; }
}
/// <summary>
/// Result of a ceremony operation.
/// </summary>
public sealed record CeremonyResult
{
/// <summary>
/// Whether the operation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Updated ceremony state.
/// </summary>
public Ceremony? Ceremony { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Error code if failed.
/// </summary>
public CeremonyErrorCode? ErrorCode { get; init; }
}
/// <summary>
/// Ceremony error codes.
/// </summary>
public enum CeremonyErrorCode
{
/// <summary>
/// Ceremony not found.
/// </summary>
NotFound,
/// <summary>
/// Ceremony has expired.
/// </summary>
Expired,
/// <summary>
/// Ceremony already executed.
/// </summary>
AlreadyExecuted,
/// <summary>
/// Ceremony was cancelled.
/// </summary>
Cancelled,
/// <summary>
/// Approver has already approved this ceremony.
/// </summary>
DuplicateApproval,
/// <summary>
/// Approver is not authorized for this operation.
/// </summary>
UnauthorizedApprover,
/// <summary>
/// Invalid approval signature.
/// </summary>
InvalidSignature,
/// <summary>
/// Threshold configuration error.
/// </summary>
InvalidThreshold,
/// <summary>
/// Internal error.
/// </summary>
InternalError
}

View File

@@ -0,0 +1,159 @@
// -----------------------------------------------------------------------------
// CeremonyOptions.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-001
// Description: Configuration options for dual-control ceremonies.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Configuration for dual-control signing ceremonies.
/// </summary>
public sealed class CeremonyOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Signer:Ceremonies";
/// <summary>
/// Whether ceremony support is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Default approval threshold (M in M-of-N).
/// </summary>
[Range(1, 10)]
public int DefaultThreshold { get; set; } = 2;
/// <summary>
/// Default ceremony expiration in minutes.
/// </summary>
[Range(5, 1440)]
public int ExpirationMinutes { get; set; } = 60;
/// <summary>
/// Per-operation configuration.
/// </summary>
public Dictionary<string, OperationCeremonyConfig> Operations { get; set; } = new();
/// <summary>
/// Notification configuration.
/// </summary>
public CeremonyNotificationConfig Notifications { get; set; } = new();
/// <summary>
/// Gets the threshold for a specific operation type.
/// </summary>
public int GetThreshold(CeremonyOperationType operationType)
{
var key = operationType.ToString().ToLowerInvariant();
if (Operations.TryGetValue(key, out var config) && config.Threshold.HasValue)
{
return config.Threshold.Value;
}
return DefaultThreshold;
}
/// <summary>
/// Gets the expiration minutes for a specific operation type.
/// </summary>
public int GetExpirationMinutes(CeremonyOperationType operationType)
{
var key = operationType.ToString().ToLowerInvariant();
if (Operations.TryGetValue(key, out var config) && config.ExpirationMinutes.HasValue)
{
return config.ExpirationMinutes.Value;
}
return ExpirationMinutes;
}
/// <summary>
/// Gets the required roles for a specific operation type.
/// </summary>
public IReadOnlyList<string> GetRequiredRoles(CeremonyOperationType operationType)
{
var key = operationType.ToString().ToLowerInvariant();
if (Operations.TryGetValue(key, out var config) && config.RequiredRoles is { Count: > 0 })
{
return config.RequiredRoles;
}
return Array.Empty<string>();
}
}
/// <summary>
/// Per-operation ceremony configuration.
/// </summary>
public sealed class OperationCeremonyConfig
{
/// <summary>
/// Approval threshold override.
/// </summary>
[Range(1, 10)]
public int? Threshold { get; set; }
/// <summary>
/// Expiration minutes override.
/// </summary>
[Range(5, 1440)]
public int? ExpirationMinutes { get; set; }
/// <summary>
/// Roles required to approve this operation.
/// </summary>
public List<string> RequiredRoles { get; set; } = [];
/// <summary>
/// Whether this operation requires a ceremony (false to bypass).
/// </summary>
public bool RequiresCeremony { get; set; } = true;
}
/// <summary>
/// Notification configuration for ceremonies.
/// </summary>
public sealed class CeremonyNotificationConfig
{
/// <summary>
/// Notification channels to use.
/// </summary>
public List<string> Channels { get; set; } = ["email"];
/// <summary>
/// Whether to notify on ceremony creation.
/// </summary>
public bool NotifyOnCreate { get; set; } = true;
/// <summary>
/// Whether to notify on each approval.
/// </summary>
public bool NotifyOnApproval { get; set; } = true;
/// <summary>
/// Whether to notify on threshold reached.
/// </summary>
public bool NotifyOnThresholdReached { get; set; } = true;
/// <summary>
/// Whether to notify on execution.
/// </summary>
public bool NotifyOnExecution { get; set; } = true;
/// <summary>
/// Whether to notify on expiration warning.
/// </summary>
public bool NotifyOnExpirationWarning { get; set; } = true;
/// <summary>
/// Minutes before expiration to send warning.
/// </summary>
[Range(5, 60)]
public int ExpirationWarningMinutes { get; set; } = 15;
}

View File

@@ -0,0 +1,552 @@
// -----------------------------------------------------------------------------
// CeremonyOrchestrator.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-005, DUAL-006, DUAL-007
// Description: Implementation of M-of-N dual-control ceremony orchestration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Orchestrates M-of-N dual-control signing ceremonies.
/// </summary>
public sealed class CeremonyOrchestrator : ICeremonyOrchestrator
{
private readonly ICeremonyRepository _repository;
private readonly ICeremonyAuditSink _auditSink;
private readonly ICeremonyApproverValidator _approverValidator;
private readonly TimeProvider _timeProvider;
private readonly CeremonyOptions _options;
private readonly ILogger<CeremonyOrchestrator> _logger;
public CeremonyOrchestrator(
ICeremonyRepository repository,
ICeremonyAuditSink auditSink,
ICeremonyApproverValidator approverValidator,
TimeProvider timeProvider,
IOptions<CeremonyOptions> options,
ILogger<CeremonyOrchestrator> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_approverValidator = approverValidator ?? throw new ArgumentNullException(nameof(approverValidator));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CeremonyResult> CreateCeremonyAsync(
CreateCeremonyRequest request,
string initiator,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(initiator);
if (!_options.Enabled)
{
return new CeremonyResult
{
Success = false,
Error = "Ceremonies are disabled",
ErrorCode = CeremonyErrorCode.InternalError
};
}
var now = _timeProvider.GetUtcNow();
var threshold = request.ThresholdOverride ?? _options.GetThreshold(request.OperationType);
var expirationMinutes = request.ExpirationMinutesOverride ?? _options.GetExpirationMinutes(request.OperationType);
if (threshold < 1)
{
return new CeremonyResult
{
Success = false,
Error = "Invalid threshold: must be at least 1",
ErrorCode = CeremonyErrorCode.InvalidThreshold
};
}
var ceremony = new Ceremony
{
CeremonyId = Guid.NewGuid(),
OperationType = request.OperationType,
Payload = request.Payload,
ThresholdRequired = threshold,
ThresholdReached = 0,
State = CeremonyState.Pending,
InitiatedBy = initiator,
InitiatedAt = now,
ExpiresAt = now.AddMinutes(expirationMinutes),
Description = request.Description,
TenantId = request.TenantId,
Approvals = []
};
var created = await _repository.CreateAsync(ceremony, cancellationToken);
await _auditSink.WriteAsync(new CeremonyInitiatedEvent
{
EventType = CeremonyAuditEvents.Initiated,
CeremonyId = created.CeremonyId,
OperationType = created.OperationType,
Timestamp = now,
Actor = initiator,
TenantId = request.TenantId,
ThresholdRequired = threshold,
ExpiresAt = created.ExpiresAt,
Description = request.Description
}, cancellationToken);
_logger.LogInformation(
"Ceremony {CeremonyId} created for {OperationType} by {Initiator}, threshold {Threshold}, expires {ExpiresAt}",
created.CeremonyId,
created.OperationType,
initiator,
threshold,
created.ExpiresAt.ToString("o", CultureInfo.InvariantCulture));
return new CeremonyResult
{
Success = true,
Ceremony = created
};
}
public async Task<CeremonyResult> ApproveCeremonyAsync(
ApproveCeremonyRequest request,
string approver,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(approver);
var now = _timeProvider.GetUtcNow();
var ceremony = await _repository.GetByIdAsync(request.CeremonyId, cancellationToken);
if (ceremony is null)
{
return new CeremonyResult
{
Success = false,
Error = "Ceremony not found",
ErrorCode = CeremonyErrorCode.NotFound
};
}
// Check expiration
if (now >= ceremony.ExpiresAt)
{
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
{
EventType = CeremonyAuditEvents.ApprovalRejected,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = approver,
AttemptedApprover = approver,
RejectionReason = "Ceremony has expired",
ErrorCode = CeremonyErrorCode.Expired
}, cancellationToken);
return new CeremonyResult
{
Success = false,
Error = "Ceremony has expired",
ErrorCode = CeremonyErrorCode.Expired
};
}
// Check state allows approval
if (!CeremonyStateMachine.CanAcceptApproval(ceremony.State))
{
var errorCode = ceremony.State switch
{
CeremonyState.Executed => CeremonyErrorCode.AlreadyExecuted,
CeremonyState.Expired => CeremonyErrorCode.Expired,
CeremonyState.Cancelled => CeremonyErrorCode.Cancelled,
_ => CeremonyErrorCode.InternalError
};
return new CeremonyResult
{
Success = false,
Error = $"Ceremony cannot accept approvals in state {ceremony.State}",
ErrorCode = errorCode
};
}
// Check for duplicate approval
if (await _repository.HasApprovedAsync(request.CeremonyId, approver, cancellationToken))
{
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
{
EventType = CeremonyAuditEvents.ApprovalRejected,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = approver,
AttemptedApprover = approver,
RejectionReason = "Approver has already approved this ceremony",
ErrorCode = CeremonyErrorCode.DuplicateApproval
}, cancellationToken);
return new CeremonyResult
{
Success = false,
Error = "You have already approved this ceremony",
ErrorCode = CeremonyErrorCode.DuplicateApproval
};
}
// Validate approver authorization
var validationResult = await _approverValidator.ValidateApproverAsync(
approver,
ceremony.OperationType,
request.ApprovalSignature,
cancellationToken);
if (!validationResult.IsValid)
{
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
{
EventType = CeremonyAuditEvents.ApprovalRejected,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = approver,
AttemptedApprover = approver,
RejectionReason = validationResult.Error ?? "Approver validation failed",
ErrorCode = validationResult.ErrorCode ?? CeremonyErrorCode.UnauthorizedApprover
}, cancellationToken);
return new CeremonyResult
{
Success = false,
Error = validationResult.Error ?? "Approver validation failed",
ErrorCode = validationResult.ErrorCode ?? CeremonyErrorCode.UnauthorizedApprover
};
}
// Add approval
var approval = new CeremonyApproval
{
ApprovalId = Guid.NewGuid(),
CeremonyId = request.CeremonyId,
ApproverIdentity = approver,
ApprovedAt = now,
ApprovalSignature = request.ApprovalSignature,
ApprovalReason = request.ApprovalReason,
SigningKeyId = request.SigningKeyId,
SignatureAlgorithm = request.SignatureAlgorithm
};
await _repository.AddApprovalAsync(approval, cancellationToken);
// Compute new state
var newThresholdReached = ceremony.ThresholdReached + 1;
var newState = CeremonyStateMachine.ComputeStateAfterApproval(
ceremony.State,
ceremony.ThresholdRequired,
newThresholdReached);
var updated = await _repository.UpdateStateAsync(
ceremony.CeremonyId,
newState,
newThresholdReached,
cancellationToken: cancellationToken);
var thresholdReached = newThresholdReached >= ceremony.ThresholdRequired;
await _auditSink.WriteAsync(new CeremonyApprovedEvent
{
EventType = CeremonyAuditEvents.Approved,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = approver,
Approver = approver,
ApprovalCount = newThresholdReached,
ThresholdRequired = ceremony.ThresholdRequired,
ApprovalReason = request.ApprovalReason,
ThresholdReached = thresholdReached
}, cancellationToken);
if (thresholdReached)
{
await _auditSink.WriteAsync(new CeremonyApprovedEvent
{
EventType = CeremonyAuditEvents.ThresholdReached,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = approver,
Approver = approver,
ApprovalCount = newThresholdReached,
ThresholdRequired = ceremony.ThresholdRequired,
ThresholdReached = true
}, cancellationToken);
_logger.LogInformation(
"Ceremony {CeremonyId} reached threshold {Threshold}, ready for execution",
ceremony.CeremonyId,
ceremony.ThresholdRequired);
}
_logger.LogInformation(
"Ceremony {CeremonyId} approved by {Approver}, {Current}/{Required} approvals",
ceremony.CeremonyId,
approver,
newThresholdReached,
ceremony.ThresholdRequired);
return new CeremonyResult
{
Success = true,
Ceremony = updated
};
}
public async Task<Ceremony?> GetCeremonyAsync(
Guid ceremonyId,
CancellationToken cancellationToken = default)
{
return await _repository.GetByIdAsync(ceremonyId, cancellationToken);
}
public async Task<IReadOnlyList<Ceremony>> ListCeremoniesAsync(
CeremonyFilter? filter = null,
CancellationToken cancellationToken = default)
{
return await _repository.ListAsync(filter, cancellationToken);
}
public async Task<CeremonyResult> ExecuteCeremonyAsync(
Guid ceremonyId,
string executor,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(executor);
var now = _timeProvider.GetUtcNow();
var ceremony = await _repository.GetByIdAsync(ceremonyId, cancellationToken);
if (ceremony is null)
{
return new CeremonyResult
{
Success = false,
Error = "Ceremony not found",
ErrorCode = CeremonyErrorCode.NotFound
};
}
if (!CeremonyStateMachine.CanExecute(ceremony.State))
{
return new CeremonyResult
{
Success = false,
Error = $"Ceremony cannot be executed in state {ceremony.State}",
ErrorCode = ceremony.State == CeremonyState.Executed
? CeremonyErrorCode.AlreadyExecuted
: CeremonyErrorCode.InternalError
};
}
// Check expiration
if (now >= ceremony.ExpiresAt)
{
return new CeremonyResult
{
Success = false,
Error = "Ceremony execution window has expired",
ErrorCode = CeremonyErrorCode.Expired
};
}
var updated = await _repository.UpdateStateAsync(
ceremonyId,
CeremonyState.Executed,
ceremony.ThresholdReached,
now,
cancellationToken);
await _auditSink.WriteAsync(new CeremonyExecutedEvent
{
EventType = CeremonyAuditEvents.Executed,
CeremonyId = ceremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = executor,
Executor = executor,
TotalApprovals = ceremony.ThresholdReached,
Success = true
}, cancellationToken);
_logger.LogInformation(
"Ceremony {CeremonyId} executed by {Executor}",
ceremonyId,
executor);
return new CeremonyResult
{
Success = true,
Ceremony = updated
};
}
public async Task<CeremonyResult> CancelCeremonyAsync(
Guid ceremonyId,
string canceller,
string? reason = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(canceller);
var now = _timeProvider.GetUtcNow();
var ceremony = await _repository.GetByIdAsync(ceremonyId, cancellationToken);
if (ceremony is null)
{
return new CeremonyResult
{
Success = false,
Error = "Ceremony not found",
ErrorCode = CeremonyErrorCode.NotFound
};
}
if (!CeremonyStateMachine.CanCancel(ceremony.State))
{
return new CeremonyResult
{
Success = false,
Error = $"Ceremony cannot be cancelled in state {ceremony.State}",
ErrorCode = CeremonyErrorCode.InternalError
};
}
var previousState = ceremony.State;
var updated = await _repository.UpdateStateAsync(
ceremonyId,
CeremonyState.Cancelled,
ceremony.ThresholdReached,
cancellationToken: cancellationToken);
await _auditSink.WriteAsync(new CeremonyCancelledEvent
{
EventType = CeremonyAuditEvents.Cancelled,
CeremonyId = ceremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = canceller,
Reason = reason,
StateAtCancellation = previousState,
ApprovalsReceived = ceremony.ThresholdReached
}, cancellationToken);
_logger.LogInformation(
"Ceremony {CeremonyId} cancelled by {Canceller}: {Reason}",
ceremonyId,
canceller,
reason ?? "(no reason provided)");
return new CeremonyResult
{
Success = true,
Ceremony = updated
};
}
public async Task<int> ProcessExpiredCeremoniesAsync(
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var expired = await _repository.GetExpiredCeremoniesAsync(now, cancellationToken);
if (expired.Count == 0)
{
return 0;
}
var ids = new List<Guid>(expired.Count);
foreach (var ceremony in expired)
{
ids.Add(ceremony.CeremonyId);
await _auditSink.WriteAsync(new CeremonyExpiredEvent
{
EventType = CeremonyAuditEvents.Expired,
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType,
Timestamp = now,
Actor = "system",
ApprovalsReceived = ceremony.ThresholdReached,
ThresholdRequired = ceremony.ThresholdRequired
}, cancellationToken);
}
var count = await _repository.MarkExpiredAsync(ids, cancellationToken);
_logger.LogInformation("Marked {Count} ceremonies as expired", count);
return count;
}
}
/// <summary>
/// Interface for ceremony audit logging.
/// </summary>
public interface ICeremonyAuditSink
{
/// <summary>
/// Writes an audit event.
/// </summary>
Task WriteAsync(CeremonyAuditEvent auditEvent, CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for validating ceremony approvers.
/// </summary>
public interface ICeremonyApproverValidator
{
/// <summary>
/// Validates an approver for a ceremony operation.
/// </summary>
Task<ApproverValidationResult> ValidateApproverAsync(
string approverIdentity,
CeremonyOperationType operationType,
byte[] signature,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of approver validation.
/// </summary>
public sealed record ApproverValidationResult
{
/// <summary>
/// Whether the approver is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Error message if invalid.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Error code if invalid.
/// </summary>
public CeremonyErrorCode? ErrorCode { get; init; }
}

View File

@@ -0,0 +1,140 @@
// -----------------------------------------------------------------------------
// CeremonyStateMachine.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-003
// Description: State machine for ceremony lifecycle management.
// -----------------------------------------------------------------------------
using System;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Manages ceremony state transitions.
/// </summary>
public static class CeremonyStateMachine
{
/// <summary>
/// Determines if a state transition is valid.
/// </summary>
/// <param name="currentState">Current ceremony state.</param>
/// <param name="targetState">Target state.</param>
/// <returns>True if transition is valid.</returns>
public static bool IsValidTransition(CeremonyState currentState, CeremonyState targetState)
{
return (currentState, targetState) switch
{
// From Pending
(CeremonyState.Pending, CeremonyState.PartiallyApproved) => true,
(CeremonyState.Pending, CeremonyState.Approved) => true, // Direct approval if threshold = 1
(CeremonyState.Pending, CeremonyState.Expired) => true,
(CeremonyState.Pending, CeremonyState.Cancelled) => true,
// From PartiallyApproved
(CeremonyState.PartiallyApproved, CeremonyState.PartiallyApproved) => true, // More approvals
(CeremonyState.PartiallyApproved, CeremonyState.Approved) => true,
(CeremonyState.PartiallyApproved, CeremonyState.Expired) => true,
(CeremonyState.PartiallyApproved, CeremonyState.Cancelled) => true,
// From Approved
(CeremonyState.Approved, CeremonyState.Executed) => true,
(CeremonyState.Approved, CeremonyState.Expired) => true, // Execution window expired
(CeremonyState.Approved, CeremonyState.Cancelled) => true,
// Terminal states - no transitions
(CeremonyState.Executed, _) => false,
(CeremonyState.Expired, _) => false,
(CeremonyState.Cancelled, _) => false,
// Same state is not a transition
_ when currentState == targetState => false,
// All other transitions are invalid
_ => false
};
}
/// <summary>
/// Computes the next state after an approval.
/// </summary>
/// <param name="currentState">Current ceremony state.</param>
/// <param name="thresholdRequired">Number of approvals required.</param>
/// <param name="thresholdReached">Number of approvals received (after this approval).</param>
/// <returns>Next state.</returns>
public static CeremonyState ComputeStateAfterApproval(
CeremonyState currentState,
int thresholdRequired,
int thresholdReached)
{
if (currentState is CeremonyState.Executed or CeremonyState.Expired or CeremonyState.Cancelled)
{
throw new InvalidOperationException($"Cannot approve ceremony in state {currentState}");
}
if (thresholdReached >= thresholdRequired)
{
return CeremonyState.Approved;
}
return CeremonyState.PartiallyApproved;
}
/// <summary>
/// Checks if a ceremony can accept approvals.
/// </summary>
/// <param name="state">Current ceremony state.</param>
/// <returns>True if approvals can be added.</returns>
public static bool CanAcceptApproval(CeremonyState state)
{
return state is CeremonyState.Pending or CeremonyState.PartiallyApproved;
}
/// <summary>
/// Checks if a ceremony can be executed.
/// </summary>
/// <param name="state">Current ceremony state.</param>
/// <returns>True if the ceremony can be executed.</returns>
public static bool CanExecute(CeremonyState state)
{
return state == CeremonyState.Approved;
}
/// <summary>
/// Checks if a ceremony can be cancelled.
/// </summary>
/// <param name="state">Current ceremony state.</param>
/// <returns>True if the ceremony can be cancelled.</returns>
public static bool CanCancel(CeremonyState state)
{
return state is CeremonyState.Pending or CeremonyState.PartiallyApproved or CeremonyState.Approved;
}
/// <summary>
/// Checks if a ceremony is in a terminal state.
/// </summary>
/// <param name="state">Current ceremony state.</param>
/// <returns>True if the ceremony is in a terminal state.</returns>
public static bool IsTerminalState(CeremonyState state)
{
return state is CeremonyState.Executed or CeremonyState.Expired or CeremonyState.Cancelled;
}
/// <summary>
/// Gets a human-readable description of the state.
/// </summary>
/// <param name="state">Ceremony state.</param>
/// <returns>Human-readable description.</returns>
public static string GetStateDescription(CeremonyState state)
{
return state switch
{
CeremonyState.Pending => "Awaiting approvals",
CeremonyState.PartiallyApproved => "Some approvals received, awaiting more",
CeremonyState.Approved => "All approvals received, ready for execution",
CeremonyState.Executed => "Operation executed successfully",
CeremonyState.Expired => "Ceremony expired before completion",
CeremonyState.Cancelled => "Ceremony was cancelled",
_ => "Unknown state"
};
}
}

View File

@@ -0,0 +1,153 @@
// -----------------------------------------------------------------------------
// ICeremonyOrchestrator.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-002
// Description: Interface for M-of-N dual-control ceremony orchestration.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Orchestrates M-of-N dual-control signing ceremonies.
/// </summary>
public interface ICeremonyOrchestrator
{
/// <summary>
/// Creates a new ceremony for the specified operation.
/// </summary>
/// <param name="request">Ceremony creation request.</param>
/// <param name="initiator">Identity of the ceremony initiator.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the created ceremony or error.</returns>
Task<CeremonyResult> CreateCeremonyAsync(
CreateCeremonyRequest request,
string initiator,
CancellationToken cancellationToken = default);
/// <summary>
/// Submits an approval for a ceremony.
/// </summary>
/// <param name="request">Approval request.</param>
/// <param name="approver">Identity of the approver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated ceremony or error.</returns>
Task<CeremonyResult> ApproveCeremonyAsync(
ApproveCeremonyRequest request,
string approver,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ceremony by ID.
/// </summary>
/// <param name="ceremonyId">Ceremony identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The ceremony or null if not found.</returns>
Task<Ceremony?> GetCeremonyAsync(
Guid ceremonyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists ceremonies with optional filters.
/// </summary>
/// <param name="filter">Optional filter criteria.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of ceremonies matching the filter.</returns>
Task<IReadOnlyList<Ceremony>> ListCeremoniesAsync(
CeremonyFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes an approved ceremony.
/// </summary>
/// <param name="ceremonyId">Ceremony to execute.</param>
/// <param name="executor">Identity of the executor.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the execution.</returns>
Task<CeremonyResult> ExecuteCeremonyAsync(
Guid ceremonyId,
string executor,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a pending ceremony.
/// </summary>
/// <param name="ceremonyId">Ceremony to cancel.</param>
/// <param name="canceller">Identity of the canceller.</param>
/// <param name="reason">Reason for cancellation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the cancellation.</returns>
Task<CeremonyResult> CancelCeremonyAsync(
Guid ceremonyId,
string canceller,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Processes expired ceremonies (background task).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of ceremonies marked as expired.</returns>
Task<int> ProcessExpiredCeremoniesAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Filter criteria for listing ceremonies.
/// </summary>
public sealed record CeremonyFilter
{
/// <summary>
/// Filter by state.
/// </summary>
public CeremonyState? State { get; init; }
/// <summary>
/// Filter by operation type.
/// </summary>
public CeremonyOperationType? OperationType { get; init; }
/// <summary>
/// Filter by initiator.
/// </summary>
public string? InitiatedBy { get; init; }
/// <summary>
/// Filter by pending approver (shows ceremonies the user can approve).
/// </summary>
public string? PendingApprover { get; init; }
/// <summary>
/// Filter ceremonies initiated after this time.
/// </summary>
public DateTimeOffset? InitiatedAfter { get; init; }
/// <summary>
/// Filter ceremonies initiated before this time.
/// </summary>
public DateTimeOffset? InitiatedBefore { get; init; }
/// <summary>
/// Include expired ceremonies.
/// </summary>
public bool IncludeExpired { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Offset for pagination.
/// </summary>
public int? Offset { get; init; }
/// <summary>
/// Tenant ID filter.
/// </summary>
public string? TenantId { get; init; }
}

View File

@@ -0,0 +1,117 @@
// -----------------------------------------------------------------------------
// ICeremonyRepository.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-009
// Description: Repository interface for ceremony persistence.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core.Ceremonies;
/// <summary>
/// Repository for ceremony persistence.
/// </summary>
public interface ICeremonyRepository
{
/// <summary>
/// Creates a new ceremony.
/// </summary>
/// <param name="ceremony">Ceremony to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Created ceremony with generated ID.</returns>
Task<Ceremony> CreateAsync(
Ceremony ceremony,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ceremony by ID.
/// </summary>
/// <param name="ceremonyId">Ceremony ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The ceremony or null if not found.</returns>
Task<Ceremony?> GetByIdAsync(
Guid ceremonyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a ceremony's state and threshold.
/// </summary>
/// <param name="ceremonyId">Ceremony ID.</param>
/// <param name="newState">New state.</param>
/// <param name="thresholdReached">New threshold reached count.</param>
/// <param name="executedAt">Execution timestamp if executed.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Updated ceremony.</returns>
Task<Ceremony?> UpdateStateAsync(
Guid ceremonyId,
CeremonyState newState,
int thresholdReached,
DateTimeOffset? executedAt = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds an approval to a ceremony.
/// </summary>
/// <param name="approval">Approval to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Created approval.</returns>
Task<CeremonyApproval> AddApprovalAsync(
CeremonyApproval approval,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if an approver has already approved a ceremony.
/// </summary>
/// <param name="ceremonyId">Ceremony ID.</param>
/// <param name="approverIdentity">Approver identity.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if already approved.</returns>
Task<bool> HasApprovedAsync(
Guid ceremonyId,
string approverIdentity,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets approvals for a ceremony.
/// </summary>
/// <param name="ceremonyId">Ceremony ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of approvals.</returns>
Task<IReadOnlyList<CeremonyApproval>> GetApprovalsAsync(
Guid ceremonyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists ceremonies matching a filter.
/// </summary>
/// <param name="filter">Filter criteria.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of ceremonies.</returns>
Task<IReadOnlyList<Ceremony>> ListAsync(
CeremonyFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets ceremonies that have expired but are not yet marked as expired.
/// </summary>
/// <param name="asOf">Time to check expiration against.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of expired ceremonies.</returns>
Task<IReadOnlyList<Ceremony>> GetExpiredCeremoniesAsync(
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks ceremonies as expired in bulk.
/// </summary>
/// <param name="ceremonyIds">Ceremony IDs to expire.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of ceremonies updated.</returns>
Task<int> MarkExpiredAsync(
IEnumerable<Guid> ceremonyIds,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,396 @@
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 Policy Decision attestation predicate type.
/// Sprint: SPRINT_3801_0001_0001_policy_decision_attestation
/// Captures policy gate results with references to input evidence (SBOM, VEX, RichGraph).
/// </summary>
public const string StellaOpsPolicyDecision = "stella.ops/policy-decision@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>
/// StellaOps Reachability Witness predicate type for DSSE attestations.
/// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
/// Cryptographic proof that specific reachability analysis was performed.
/// </summary>
public const string StellaOpsReachabilityWitness = "stella.ops/reachabilityWitness@v1";
/// <summary>
/// StellaOps Path Witness predicate type for DSSE attestations.
/// Sprint: SPRINT_3700_0001_0001 (WIT-007C)
/// Cryptographic proof of a specific entrypoint to sink path.
/// Used by PathWitnessBuilder to sign individual path witnesses.
/// </summary>
public const string StellaOpsPathWitness = "stella.ops/pathWitness@v1";
// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-001)
// Canonical predicate type and aliases for path witness attestations.
/// <summary>
/// Canonical Path Witness predicate type (SIGNER-PW-001).
/// </summary>
public const string PathWitnessCanonical = "https://stella.ops/predicates/path-witness/v1";
/// <summary>
/// Path Witness predicate alias 1 (SIGNER-PW-001).
/// </summary>
public const string PathWitnessAlias1 = "stella.ops/pathWitness@v1";
/// <summary>
/// Path Witness predicate alias 2 (SIGNER-PW-001).
/// </summary>
public const string PathWitnessAlias2 = "https://stella.ops/pathWitness/v1";
/// <summary>
/// StellaOps Reachability Drift predicate type for DSSE attestations.
/// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-014)
/// Cryptographic proof of reachability changes between scans.
/// Used by DriftAttestationService to sign drift analysis results.
/// </summary>
public const string StellaOpsReachabilityDrift = "stellaops.dev/predicates/reachability-drift@v1";
/// <summary>
/// StellaOps Verdict predicate type for security assessment results.
/// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
/// Captures the final security verdict for an artifact, including:
/// - Pass/Warn/Fail status with gate evaluation results
/// - Delta summary (newly reachable/unreachable CVEs)
/// - References to supporting evidence (SBOM, VEX, reachability graph)
/// - Risk metrics (CVSS, EPSS, KEV status)
/// Used by keyless signing workflows to attest verdicts in CI/CD pipelines.
/// </summary>
public const string StellaOpsVerdict = "stella.ops/verdict@v1";
/// <summary>
/// StellaOps Verdict predicate type alternate URI form (legacy compatibility).
/// </summary>
public const string StellaOpsVerdictAlt = "verdict.stella/v1";
// -------------------------------------------------------------------------
// Delta Predicate Types
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-024)
// Delta attestations capture changes between lineage node versions.
// -------------------------------------------------------------------------
/// <summary>
/// StellaOps VEX Delta predicate type.
/// Captures changes in VEX consensus status between two SBOM versions.
/// Contains: from_digest, to_digest, status_changes[], new_vulns[], resolved_vulns[].
/// </summary>
public const string StellaOpsVexDelta = "stella.ops/vex-delta@v1";
/// <summary>
/// StellaOps SBOM Delta predicate type.
/// Captures changes in SBOM composition between two versions.
/// Contains: from_digest, to_digest, components_added[], components_removed[],
/// version_changes[], license_changes[].
/// </summary>
public const string StellaOpsSbomDelta = "stella.ops/sbom-delta@v1";
/// <summary>
/// StellaOps Verdict Delta predicate type.
/// Captures changes in security verdicts between two evaluations.
/// Contains: from_digest, to_digest, gate_changes[], risk_delta,
/// reachability_delta, attestation_refs[].
/// </summary>
public const string StellaOpsVerdictDelta = "stella.ops/verdict-delta@v1";
/// <summary>
/// StellaOps Reachability Delta predicate type.
/// Captures changes in reachability analysis between two versions.
/// Contains: from_digest, to_digest, paths_added, paths_removed,
/// gates_changed[], entrypoints_changed[].
/// </summary>
public const string StellaOpsReachabilityDelta = "stella.ops/reachability-delta@v1";
// -------------------------------------------------------------------------
// Runtime Linkage Verification Types
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification (RLV-001)
// Function map predicates for runtime→static linkage verification.
// -------------------------------------------------------------------------
/// <summary>
/// StellaOps Function Map predicate type (canonical).
/// Declares expected call-paths for runtime observation verification.
/// Contains: service, expectedPaths[], coverage thresholds.
/// Used by runtime linkage verification to prove runtime matches static analysis.
/// </summary>
public const string StellaOpsFunctionMap = "https://stella.ops/predicates/function-map/v1";
/// <summary>
/// StellaOps Function Map predicate type (legacy alias).
/// </summary>
public const string StellaOpsFunctionMapAlias = "stella.ops/functionMap@v1";
// -------------------------------------------------------------------------
// Runtime Evidence Types
// Sprint: SPRINT_0127_0002_Signals_ebpf_syscall_reachability_proofs (SIGNING-001)
// Runtime evidence predicates for eBPF-collected syscall/uprobe evidence chunks.
// -------------------------------------------------------------------------
/// <summary>
/// StellaOps Runtime Evidence predicate type.
/// Captures signed evidence chunks from eBPF runtime observation.
/// Contains: chunk_id, chunk_sequence, previous_chunk_id, event_count,
/// time_range, collector_version, kernel_version.
/// Used by EvidenceChunkFinalizer to sign rotating NDJSON chunks.
/// </summary>
public const string StellaOpsRuntimeEvidence = "stella.ops/runtime-evidence@v1";
/// <summary>
/// StellaOps Runtime Evidence predicate type (canonical URL form).
/// </summary>
public const string StellaOpsRuntimeEvidenceCanonical = "https://stella.ops/predicates/runtime-evidence/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.
/// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-003)
/// Updated to recognize https://stella.ops/ and https://stella-ops.org/ URIs as StellaOps types.
/// </summary>
public static bool IsStellaOpsType(string predicateType)
{
if (string.IsNullOrEmpty(predicateType))
return false;
// Legacy format: stella.ops/type@version
if (predicateType.StartsWith("stella.ops/", StringComparison.Ordinal))
return true;
// Canonical HTTPS format: https://stella.ops/predicates/...
if (predicateType.StartsWith("https://stella.ops/", StringComparison.Ordinal))
return true;
// Alternate domain format: https://stella-ops.org/predicates/...
if (predicateType.StartsWith("https://stella-ops.org/", StringComparison.Ordinal))
return true;
return false;
}
/// <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
|| predicateType == StellaOpsReachabilityWitness
|| predicateType == StellaOpsPathWitness
|| predicateType == StellaOpsReachabilityDrift
|| predicateType == StellaOpsReachabilityDelta
// Path Witness canonical and aliases (SIGNER-PW-001)
|| predicateType == PathWitnessCanonical
|| predicateType == PathWitnessAlias1
|| predicateType == PathWitnessAlias2
// Function Map types (RLV-001)
|| predicateType == StellaOpsFunctionMap
|| predicateType == StellaOpsFunctionMapAlias
// Runtime Evidence types (SIGNING-001)
|| predicateType == StellaOpsRuntimeEvidence
|| predicateType == StellaOpsRuntimeEvidenceCanonical;
}
/// <summary>
/// Determines if the predicate type is a function map type (canonical or alias).
/// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification (RLV-001)
/// </summary>
public static bool IsFunctionMapType(string predicateType)
{
return predicateType == StellaOpsFunctionMap
|| predicateType == StellaOpsFunctionMapAlias;
}
/// <summary>
/// Determines if the predicate type is a runtime evidence type (canonical or legacy).
/// Sprint: SPRINT_0127_0002_Signals_ebpf_syscall_reachability_proofs (SIGNING-001)
/// </summary>
public static bool IsRuntimeEvidenceType(string predicateType)
{
return predicateType == StellaOpsRuntimeEvidence
|| predicateType == StellaOpsRuntimeEvidenceCanonical;
}
/// <summary>
/// Determines if the predicate type is a path witness type (canonical or alias).
/// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-001)
/// </summary>
public static bool IsPathWitnessType(string predicateType)
{
return predicateType == PathWitnessCanonical
|| predicateType == PathWitnessAlias1
|| predicateType == PathWitnessAlias2
|| predicateType == StellaOpsPathWitness;
}
/// <summary>
/// Determines if the predicate type is a verdict/decision type.
/// </summary>
public static bool IsVerdictType(string predicateType)
{
return predicateType == StellaOpsVerdict
|| predicateType == StellaOpsVerdictAlt
|| predicateType == StellaOpsPolicy
|| predicateType == StellaOpsPolicyDecision
|| predicateType == StellaOpsVerdictDelta;
}
/// <summary>
/// Determines if the predicate type is a delta/change tracking type.
/// Sprint: LIN-BE-024
/// </summary>
public static bool IsDeltaType(string predicateType)
{
return predicateType == StellaOpsVexDelta
|| predicateType == StellaOpsSbomDelta
|| predicateType == StellaOpsVerdictDelta
|| predicateType == StellaOpsReachabilityDelta;
}
/// <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,
StellaOpsPolicyDecision,
StellaOpsEvidence,
StellaOpsVexDecision,
StellaOpsGraph,
StellaOpsReachabilityWitness,
StellaOpsPathWitness,
StellaOpsReachabilityDrift,
StellaOpsVerdict,
StellaOpsVerdictAlt,
// Path Witness canonical + aliases (SIGNER-PW-001)
PathWitnessCanonical,
PathWitnessAlias1,
PathWitnessAlias2,
// Delta types (LIN-BE-024)
StellaOpsVexDelta,
StellaOpsSbomDelta,
StellaOpsVerdictDelta,
StellaOpsReachabilityDelta,
// Function Map types (RLV-001)
StellaOpsFunctionMap,
StellaOpsFunctionMapAlias,
// Runtime Evidence types (SIGNING-001)
StellaOpsRuntimeEvidence,
StellaOpsRuntimeEvidenceCanonical,
// 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

@@ -0,0 +1,462 @@
// -----------------------------------------------------------------------------
// DeltaPredicateSchemas.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-024)
// Task: Define delta predicate schemas for lineage change tracking
// Description: JSON-serializable models for delta predicate payloads.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Signer.Core.Predicates;
/// <summary>
/// Base class for delta predicates capturing changes between lineage versions.
/// </summary>
public abstract record DeltaPredicateBase
{
/// <summary>
/// Digest of the source/from artifact.
/// </summary>
[JsonPropertyName("fromDigest")]
public required string FromDigest { get; init; }
/// <summary>
/// Digest of the target/to artifact.
/// </summary>
[JsonPropertyName("toDigest")]
public required string ToDigest { get; init; }
/// <summary>
/// UTC timestamp when the delta was computed.
/// </summary>
[JsonPropertyName("computedAt")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Tenant identifier for multi-tenancy.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
}
/// <summary>
/// VEX Delta predicate capturing VEX consensus status changes.
/// Predicate type: stella.ops/vex-delta@v1
/// </summary>
public sealed record VexDeltaPredicate : DeltaPredicateBase
{
/// <summary>
/// VEX status changes between versions.
/// </summary>
[JsonPropertyName("statusChanges")]
public ImmutableArray<VexStatusChange> StatusChanges { get; init; } = ImmutableArray<VexStatusChange>.Empty;
/// <summary>
/// New vulnerabilities discovered in the target version.
/// </summary>
[JsonPropertyName("newVulnerabilities")]
public ImmutableArray<VexVulnerabilityEntry> NewVulnerabilities { get; init; } = ImmutableArray<VexVulnerabilityEntry>.Empty;
/// <summary>
/// Vulnerabilities resolved (fixed or not_affected) in target version.
/// </summary>
[JsonPropertyName("resolvedVulnerabilities")]
public ImmutableArray<VexVulnerabilityEntry> ResolvedVulnerabilities { get; init; } = ImmutableArray<VexVulnerabilityEntry>.Empty;
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("summary")]
public required VexDeltaSummary Summary { get; init; }
}
/// <summary>
/// Individual VEX status change entry.
/// </summary>
public sealed record VexStatusChange
{
[JsonPropertyName("cve")]
public required string Cve { get; init; }
[JsonPropertyName("fromStatus")]
public required string FromStatus { get; init; }
[JsonPropertyName("toStatus")]
public required string ToStatus { get; init; }
[JsonPropertyName("fromJustification")]
public string? FromJustification { get; init; }
[JsonPropertyName("toJustification")]
public string? ToJustification { get; init; }
[JsonPropertyName("product")]
public string? Product { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Vulnerability entry in a VEX delta.
/// </summary>
public sealed record VexVulnerabilityEntry
{
[JsonPropertyName("cve")]
public required string Cve { get; init; }
[JsonPropertyName("status")]
public required string Status { get; init; }
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("cvss")]
public double? Cvss { get; init; }
[JsonPropertyName("product")]
public string? Product { get; init; }
}
/// <summary>
/// Summary of VEX delta changes.
/// </summary>
public sealed record VexDeltaSummary
{
[JsonPropertyName("statusChangeCount")]
public int StatusChangeCount { get; init; }
[JsonPropertyName("newVulnCount")]
public int NewVulnCount { get; init; }
[JsonPropertyName("resolvedVulnCount")]
public int ResolvedVulnCount { get; init; }
[JsonPropertyName("criticalNew")]
public int CriticalNew { get; init; }
[JsonPropertyName("highNew")]
public int HighNew { get; init; }
}
/// <summary>
/// SBOM Delta predicate capturing composition changes.
/// Predicate type: stella.ops/sbom-delta@v1
/// </summary>
public sealed record SbomDeltaPredicate : DeltaPredicateBase
{
/// <summary>
/// Components added in the target version.
/// </summary>
[JsonPropertyName("componentsAdded")]
public ImmutableArray<SbomComponentEntry> ComponentsAdded { get; init; } = ImmutableArray<SbomComponentEntry>.Empty;
/// <summary>
/// Components removed in the target version.
/// </summary>
[JsonPropertyName("componentsRemoved")]
public ImmutableArray<SbomComponentEntry> ComponentsRemoved { get; init; } = ImmutableArray<SbomComponentEntry>.Empty;
/// <summary>
/// Components with version changes.
/// </summary>
[JsonPropertyName("versionChanges")]
public ImmutableArray<SbomVersionChangeEntry> VersionChanges { get; init; } = ImmutableArray<SbomVersionChangeEntry>.Empty;
/// <summary>
/// Components with license changes.
/// </summary>
[JsonPropertyName("licenseChanges")]
public ImmutableArray<SbomLicenseChangeEntry> LicenseChanges { get; init; } = ImmutableArray<SbomLicenseChangeEntry>.Empty;
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("summary")]
public required SbomDeltaSummary Summary { get; init; }
}
/// <summary>
/// Component entry in SBOM delta.
/// </summary>
public sealed record SbomComponentEntry
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
}
/// <summary>
/// Version change entry in SBOM delta.
/// </summary>
public sealed record SbomVersionChangeEntry
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("fromVersion")]
public required string FromVersion { get; init; }
[JsonPropertyName("toVersion")]
public required string ToVersion { get; init; }
}
/// <summary>
/// License change entry in SBOM delta.
/// </summary>
public sealed record SbomLicenseChangeEntry
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("fromLicense")]
public string? FromLicense { get; init; }
[JsonPropertyName("toLicense")]
public string? ToLicense { get; init; }
}
/// <summary>
/// Summary of SBOM delta changes.
/// </summary>
public sealed record SbomDeltaSummary
{
[JsonPropertyName("addedCount")]
public int AddedCount { get; init; }
[JsonPropertyName("removedCount")]
public int RemovedCount { get; init; }
[JsonPropertyName("versionChangedCount")]
public int VersionChangedCount { get; init; }
[JsonPropertyName("licenseChangedCount")]
public int LicenseChangedCount { get; init; }
}
/// <summary>
/// Verdict Delta predicate capturing security verdict changes.
/// Predicate type: stella.ops/verdict-delta@v1
/// </summary>
public sealed record VerdictDeltaPredicate : DeltaPredicateBase
{
/// <summary>
/// Gate evaluation changes.
/// </summary>
[JsonPropertyName("gateChanges")]
public ImmutableArray<GateChangeEntry> GateChanges { get; init; } = ImmutableArray<GateChangeEntry>.Empty;
/// <summary>
/// Change in overall risk assessment.
/// </summary>
[JsonPropertyName("riskDelta")]
public RiskDelta? RiskDelta { get; init; }
/// <summary>
/// Summary of reachability changes.
/// </summary>
[JsonPropertyName("reachabilityDelta")]
public ReachabilityDeltaSummary? ReachabilityDelta { get; init; }
/// <summary>
/// References to supporting attestations.
/// </summary>
[JsonPropertyName("attestationRefs")]
public ImmutableArray<AttestationRef> AttestationRefs { get; init; } = ImmutableArray<AttestationRef>.Empty;
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("summary")]
public required VerdictDeltaSummary Summary { get; init; }
}
/// <summary>
/// Gate change entry in verdict delta.
/// </summary>
public sealed record GateChangeEntry
{
[JsonPropertyName("gateName")]
public required string GateName { get; init; }
[JsonPropertyName("fromResult")]
public required string FromResult { get; init; }
[JsonPropertyName("toResult")]
public required string ToResult { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Risk score delta.
/// </summary>
public sealed record RiskDelta
{
[JsonPropertyName("fromScore")]
public double FromScore { get; init; }
[JsonPropertyName("toScore")]
public double ToScore { get; init; }
[JsonPropertyName("fromCvssMax")]
public double? FromCvssMax { get; init; }
[JsonPropertyName("toCvssMax")]
public double? ToCvssMax { get; init; }
[JsonPropertyName("kevCountChange")]
public int KevCountChange { get; init; }
}
/// <summary>
/// Reachability changes summary.
/// </summary>
public sealed record ReachabilityDeltaSummary
{
[JsonPropertyName("pathsAdded")]
public int PathsAdded { get; init; }
[JsonPropertyName("pathsRemoved")]
public int PathsRemoved { get; init; }
[JsonPropertyName("newlyReachableCves")]
public ImmutableArray<string> NewlyReachableCves { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("newlyUnreachableCves")]
public ImmutableArray<string> NewlyUnreachableCves { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Reference to a supporting attestation.
/// </summary>
public sealed record AttestationRef
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
}
/// <summary>
/// Summary of verdict delta changes.
/// </summary>
public sealed record VerdictDeltaSummary
{
[JsonPropertyName("gateChangeCount")]
public int GateChangeCount { get; init; }
[JsonPropertyName("overallStatusChange")]
public bool OverallStatusChange { get; init; }
[JsonPropertyName("fromStatus")]
public string? FromStatus { get; init; }
[JsonPropertyName("toStatus")]
public string? ToStatus { get; init; }
}
/// <summary>
/// Reachability Delta predicate capturing call-graph changes.
/// Predicate type: stella.ops/reachability-delta@v1
/// </summary>
public sealed record ReachabilityDeltaPredicate : DeltaPredicateBase
{
/// <summary>
/// CVE identifier if this delta is for a specific vulnerability.
/// </summary>
[JsonPropertyName("cve")]
public string? Cve { get; init; }
/// <summary>
/// Number of paths added.
/// </summary>
[JsonPropertyName("pathsAdded")]
public int PathsAdded { get; init; }
/// <summary>
/// Number of paths removed.
/// </summary>
[JsonPropertyName("pathsRemoved")]
public int PathsRemoved { get; init; }
/// <summary>
/// New entrypoints discovered.
/// </summary>
[JsonPropertyName("entrypointsAdded")]
public ImmutableArray<string> EntrypointsAdded { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Entrypoints no longer present.
/// </summary>
[JsonPropertyName("entrypointsRemoved")]
public ImmutableArray<string> EntrypointsRemoved { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gates that changed status.
/// </summary>
[JsonPropertyName("gatesChanged")]
public ImmutableArray<GateStatusChange> GatesChanged { get; init; } = ImmutableArray<GateStatusChange>.Empty;
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("summary")]
public required ReachabilityDeltaDetailSummary Summary { get; init; }
}
/// <summary>
/// Gate status change in reachability delta.
/// </summary>
public sealed record GateStatusChange
{
[JsonPropertyName("gateName")]
public required string GateName { get; init; }
[JsonPropertyName("fromStatus")]
public required string FromStatus { get; init; }
[JsonPropertyName("toStatus")]
public required string ToStatus { get; init; }
}
/// <summary>
/// Detailed summary for reachability delta.
/// </summary>
public sealed record ReachabilityDeltaDetailSummary
{
[JsonPropertyName("totalPathsAdded")]
public int TotalPathsAdded { get; init; }
[JsonPropertyName("totalPathsRemoved")]
public int TotalPathsRemoved { get; init; }
[JsonPropertyName("entrypointChangeCount")]
public int EntrypointChangeCount { get; init; }
[JsonPropertyName("gateChangeCount")]
public int GateChangeCount { get; init; }
[JsonPropertyName("reachabilityChanged")]
public bool ReachabilityChanged { get; init; }
}

View File

@@ -0,0 +1,55 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public interface IProofOfEntitlementIntrospector
{
ValueTask<ProofOfEntitlementResult> IntrospectAsync(
ProofOfEntitlement proof,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IReleaseIntegrityVerifier
{
ValueTask<ReleaseVerificationResult> VerifyAsync(
string scannerImageDigest,
CancellationToken cancellationToken);
}
public interface ISignerQuotaService
{
ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IDsseSigner
{
ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerAuditSink
{
ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerPipeline
{
ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Signer.Core;
public enum SignerPoEFormat
{
Jwt,
Mtls,
}
public enum SigningMode
{
Keyless,
Kms,
}
public sealed record SigningSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public sealed record ProofOfEntitlement(
SignerPoEFormat Format,
string Value);
public sealed record SigningOptions(
SigningMode Mode,
int? ExpirySeconds,
string ReturnBundle);
public sealed record SigningRequest(
IReadOnlyList<SigningSubject> Subjects,
string PredicateType,
JsonDocument Predicate,
string ScannerImageDigest,
ProofOfEntitlement ProofOfEntitlement,
SigningOptions Options);
public sealed record CallerContext(
string Subject,
string Tenant,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Audiences,
string? SenderBinding,
string? ClientCertificateThumbprint);
public sealed record ProofOfEntitlementResult(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);
public sealed record ReleaseVerificationResult(
bool Trusted,
string? ReleaseSigner);
public sealed record SigningIdentity(
string Mode,
string Issuer,
string Subject,
DateTimeOffset? ExpiresAtUtc);
public sealed record SigningMetadata(
SigningIdentity Identity,
IReadOnlyList<string> CertificateChain,
string ProviderName,
string AlgorithmId);
public sealed record SigningBundle(
DsseEnvelope Envelope,
SigningMetadata Metadata);
public sealed record PolicyCounters(
string Plan,
int MaxArtifactBytes,
int QpsRemaining);
public sealed record SigningOutcome(
SigningBundle Bundle,
PolicyCounters Policy,
string AuditId);
public sealed record SignerAuditEntry(
string AuditId,
DateTimeOffset TimestampUtc,
string Subject,
string Tenant,
string Plan,
string ScannerImageDigest,
string SigningMode,
string ProviderName,
IReadOnlyList<SigningSubject> Subjects);
public sealed record DsseEnvelope(
string Payload,
string PayloadType,
IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(
string Signature,
string? KeyId);

View File

@@ -0,0 +1,46 @@
using System;
namespace StellaOps.Signer.Core;
public abstract class SignerException : Exception
{
protected SignerException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
public sealed class SignerValidationException : SignerException
{
public SignerValidationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerAuthorizationException : SignerException
{
public SignerAuthorizationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerReleaseVerificationException : SignerException
{
public SignerReleaseVerificationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerQuotaException : SignerException
{
public SignerQuotaException(string code, string message)
: base(code, message)
{
}
}

View File

@@ -0,0 +1,147 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public sealed class SignerPipeline : ISignerPipeline
{
private const string RequiredScope = "signer.sign";
private const string RequiredAudience = "signer";
private readonly IProofOfEntitlementIntrospector _poe;
private readonly IReleaseIntegrityVerifier _releaseVerifier;
private readonly ISignerQuotaService _quotaService;
private readonly IDsseSigner _signer;
private readonly ISignerAuditSink _auditSink;
private readonly TimeProvider _timeProvider;
public SignerPipeline(
IProofOfEntitlementIntrospector poe,
IReleaseIntegrityVerifier releaseVerifier,
ISignerQuotaService quotaService,
IDsseSigner signer,
ISignerAuditSink auditSink,
TimeProvider timeProvider)
{
_poe = poe ?? throw new ArgumentNullException(nameof(poe));
_releaseVerifier = releaseVerifier ?? throw new ArgumentNullException(nameof(releaseVerifier));
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(caller);
ValidateCaller(caller);
ValidateRequest(request);
var entitlement = await _poe
.IntrospectAsync(request.ProofOfEntitlement, caller, cancellationToken)
.ConfigureAwait(false);
if (entitlement.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is expired.");
}
var releaseResult = await _releaseVerifier
.VerifyAsync(request.ScannerImageDigest, cancellationToken)
.ConfigureAwait(false);
if (!releaseResult.Trusted)
{
throw new SignerReleaseVerificationException("release_untrusted", "Scanner image digest failed release verification.");
}
await _quotaService
.EnsureWithinLimitsAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var bundle = await _signer
.SignAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var auditId = await _auditSink
.WriteAsync(request, bundle, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var outcome = new SigningOutcome(
bundle,
new PolicyCounters(entitlement.Plan, entitlement.MaxArtifactBytes, entitlement.QpsRemaining),
auditId);
return outcome;
}
private static void ValidateCaller(CallerContext caller)
{
if (string.IsNullOrWhiteSpace(caller.Subject))
{
throw new SignerAuthorizationException("invalid_caller", "Caller subject is required.");
}
if (string.IsNullOrWhiteSpace(caller.Tenant))
{
throw new SignerAuthorizationException("invalid_caller", "Caller tenant is required.");
}
if (!caller.Scopes.Contains(RequiredScope, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("insufficient_scope", $"Scope '{RequiredScope}' is required.");
}
if (!caller.Audiences.Contains(RequiredAudience, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("invalid_audience", $"Audience '{RequiredAudience}' is required.");
}
}
private static void ValidateRequest(SigningRequest request)
{
if (request.Subjects.Count == 0)
{
throw new SignerValidationException("subject_missing", "At least one subject must be provided.");
}
foreach (var subject in request.Subjects)
{
if (string.IsNullOrWhiteSpace(subject.Name))
{
throw new SignerValidationException("subject_invalid", "Subject name is required.");
}
if (subject.Digest is null || subject.Digest.Count == 0)
{
throw new SignerValidationException("subject_digest_invalid", "Subject digest is required.");
}
}
if (string.IsNullOrWhiteSpace(request.PredicateType))
{
throw new SignerValidationException("predicate_type_missing", "Predicate type is required.");
}
if (request.Predicate is null || request.Predicate.RootElement.ValueKind == JsonValueKind.Undefined)
{
throw new SignerValidationException("predicate_missing", "Predicate payload is required.");
}
if (string.IsNullOrWhiteSpace(request.ScannerImageDigest))
{
throw new SignerValidationException("scanner_digest_missing", "Scanner image digest is required.");
}
if (request.ProofOfEntitlement is null)
{
throw new SignerValidationException("poe_missing", "Proof of entitlement is required.");
}
}
}

View File

@@ -0,0 +1,170 @@
using StellaOps.Provenance.Attestation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
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 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);
}
/// <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 InTotoSubject(subject.Name, digest));
}
return subjects;
}
/// <summary>
/// Normalizes predicate type URIs for consistency.
/// </summary>
private static string NormalizePredicateType(string predicateType)
{
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
{
_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;
}
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))
{
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

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.Signer.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Signer/StellaOps.Signer/StellaOps.Signer.Core/StellaOps.Signer.Core.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.Signer.Core;
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Auditing;
public sealed class InMemorySignerAuditSink : ISignerAuditSink
{
private readonly ConcurrentDictionary<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<InMemorySignerAuditSink> _logger;
public InMemorySignerAuditSink(
TimeProvider timeProvider,
ILogger<InMemorySignerAuditSink> logger,
IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var auditId = _guidProvider.NewGuid().ToString("d");
var entry = new SignerAuditEntry(
auditId,
_timeProvider.GetUtcNow(),
caller.Subject,
caller.Tenant,
entitlement.Plan,
request.ScannerImageDigest,
bundle.Metadata.Identity.Mode,
bundle.Metadata.ProviderName,
request.Subjects);
_entries[auditId] = entry;
_logger.LogInformation("Signer audit event {AuditId} recorded for tenant {Tenant}", auditId, caller.Tenant);
return ValueTask.FromResult(auditId);
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerCryptoOptions
{
public string KeyId { get; set; } = "signer-kms-default";
public string AlgorithmId { get; set; } = "HS256";
public string Secret { get; set; } = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("stellaops-signer-secret"));
public string ProviderName { get; set; } = "InMemoryHmacProvider";
public string Mode { get; set; } = "kms";
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerEntitlementOptions
{
public IDictionary<string, SignerEntitlementDefinition> Tokens { get; } =
new Dictionary<string, SignerEntitlementDefinition>(StringComparer.Ordinal);
}
public sealed record SignerEntitlementDefinition(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerReleaseVerificationOptions
{
public ISet<string> TrustedScannerDigests { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public string TrustedSigner { get; set; } = "StellaOps Release";
}

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Options;
using ProofOfEntitlementRecord = StellaOps.Signer.Core.ProofOfEntitlement;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.ProofOfEntitlement;
public sealed class InMemoryProofOfEntitlementIntrospector : IProofOfEntitlementIntrospector
{
private readonly IOptionsMonitor<SignerEntitlementOptions> _options;
private readonly TimeProvider _timeProvider;
public InMemoryProofOfEntitlementIntrospector(
IOptionsMonitor<SignerEntitlementOptions> options,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<ProofOfEntitlementResult> IntrospectAsync(
ProofOfEntitlementRecord proof,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proof);
ArgumentNullException.ThrowIfNull(caller);
var token = proof.Value ?? string.Empty;
var snapshot = _options.CurrentValue;
if (!snapshot.Tokens.TryGetValue(token, out var definition))
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is invalid or revoked.");
}
if (definition.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement has expired.");
}
var result = new ProofOfEntitlementResult(
definition.LicenseId,
definition.CustomerId,
definition.Plan,
definition.MaxArtifactBytes,
definition.QpsLimit,
definition.QpsRemaining,
definition.ExpiresAtUtc);
return ValueTask.FromResult(result);
}
}

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core;
using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Quotas;
public sealed class InMemoryQuotaService : ISignerQuotaService
{
private readonly ConcurrentDictionary<string, QuotaWindow> _windows = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryQuotaService> _logger;
public InMemoryQuotaService(TimeProvider timeProvider, ILogger<InMemoryQuotaService> logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var payloadSize = EstimatePayloadSize(request);
if (payloadSize > entitlement.MaxArtifactBytes)
{
throw new SignerQuotaException("artifact_too_large", $"Artifact size {payloadSize} exceeds plan cap ({entitlement.MaxArtifactBytes}).");
}
if (entitlement.QpsLimit <= 0)
{
return ValueTask.CompletedTask;
}
var window = _windows.GetOrAdd(caller.Tenant, static _ => new QuotaWindow());
lock (window)
{
var now = _timeProvider.GetUtcNow();
if (window.ResetAt <= now)
{
window.Reset(now, entitlement.QpsLimit);
}
if (window.Remaining <= 0)
{
_logger.LogWarning("Quota exceeded for tenant {Tenant}", caller.Tenant);
throw new SignerQuotaException("plan_throttled", "Plan QPS limit exceeded.");
}
window.Remaining--;
window.LastUpdated = now;
}
return ValueTask.CompletedTask;
}
private static int EstimatePayloadSize(SigningRequest request)
{
var predicateBytes = request.Predicate is null
? Array.Empty<byte>()
: Encoding.UTF8.GetBytes(request.Predicate.RootElement.GetRawText());
var subjectBytes = 0;
foreach (var subject in request.Subjects)
{
subjectBytes += subject.Name.Length;
foreach (var digest in subject.Digest)
{
subjectBytes += digest.Key.Length + digest.Value.Length;
}
}
return predicateBytes.Length + subjectBytes;
}
private sealed class QuotaWindow
{
public DateTimeOffset ResetAt { get; private set; } = DateTimeOffset.MinValue;
public int Remaining { get; set; }
public DateTimeOffset LastUpdated { get; set; }
public void Reset(DateTimeOffset now, int limit)
{
ResetAt = now.AddSeconds(1);
Remaining = limit;
LastUpdated = now;
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.ReleaseVerification;
public sealed class DefaultReleaseIntegrityVerifier : IReleaseIntegrityVerifier
{
private static readonly Regex DigestPattern = new("^sha256:[a-fA-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IOptionsMonitor<SignerReleaseVerificationOptions> _options;
public DefaultReleaseIntegrityVerifier(IOptionsMonitor<SignerReleaseVerificationOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public ValueTask<ReleaseVerificationResult> VerifyAsync(string scannerImageDigest, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scannerImageDigest) || !DigestPattern.IsMatch(scannerImageDigest))
{
throw new SignerReleaseVerificationException("release_digest_invalid", "Scanner image digest must be a valid sha256 string.");
}
var options = _options.CurrentValue;
if (options.TrustedScannerDigests.Count > 0 &&
!options.TrustedScannerDigests.Contains(scannerImageDigest))
{
return ValueTask.FromResult(new ReleaseVerificationResult(false, null));
}
return ValueTask.FromResult(new ReleaseVerificationResult(true, options.TrustedSigner));
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Auditing;
using StellaOps.Signer.Infrastructure.ProofOfEntitlement;
using StellaOps.Signer.Infrastructure.Quotas;
using StellaOps.Signer.Infrastructure.ReleaseVerification;
using StellaOps.Signer.Infrastructure.Signing;
namespace StellaOps.Signer.Infrastructure;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSignerPipeline(this IServiceCollection services)
{
services.AddSingleton<ISignerPipeline, SignerPipeline>();
services.AddSingleton<IProofOfEntitlementIntrospector, InMemoryProofOfEntitlementIntrospector>();
services.AddSingleton<IReleaseIntegrityVerifier, DefaultReleaseIntegrityVerifier>();
services.AddSingleton<ISignerQuotaService, InMemoryQuotaService>();
services.AddSingleton<IDsseSigner, HmacDsseSigner>();
services.AddSingleton<ISignerAuditSink, InMemorySignerAuditSink>();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
return services;
}
}

View File

@@ -0,0 +1,281 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
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 = ResolveAndValidateAlgorithm(null, 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 (primary)
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);
var signatures = new List<DsseSignature>
{
new DsseSignature(signatureBase64, signer.KeyId)
};
if (!string.IsNullOrWhiteSpace(_options.SecondaryAlgorithm))
{
var secondaryAlgorithm = ResolveAndValidateAlgorithm(_options.SecondaryAlgorithm, signingMode);
var secondaryKeyId = _options.SecondaryKeyId ?? keyReference.KeyId;
var secondaryProviderHint = _options.SecondaryProvider ?? keyResolution.ProviderHint;
var secondaryRef = new CryptoKeyReference(secondaryKeyId, secondaryProviderHint);
var secondaryResolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
secondaryAlgorithm,
secondaryRef,
secondaryProviderHint);
var secondarySignatureBytes = await secondaryResolution.Signer
.SignAsync(paeBytes, cancellationToken)
.ConfigureAwait(false);
var secondarySignatureBase64 = Convert.ToBase64String(secondarySignatureBytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
signatures.Add(new DsseSignature(secondarySignatureBase64, secondaryResolution.Signer.KeyId));
_logger.LogInformation(
"Added secondary DSSE signature using provider {Provider} algorithm {Algorithm} key {KeyId}",
secondaryResolution.ProviderName,
secondaryAlgorithm,
secondaryResolution.Signer.KeyId);
}
// Build DSSE envelope
var envelope = new DsseEnvelope(
Payload: payloadBase64,
PayloadType: DssePayloadType,
Signatures: signatures);
// 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 ResolveAndValidateAlgorithm(string? preferredOverride, SigningMode mode)
{
var preferred = mode switch
{
SigningMode.Keyless => preferredOverride ?? _options.KeylessAlgorithm ?? SignatureAlgorithms.Es256,
SigningMode.Kms => preferredOverride ?? _options.KmsAlgorithm ?? SignatureAlgorithms.Es256,
_ => SignatureAlgorithms.Es256
};
ValidateAlgorithmGate(preferred);
return preferred;
}
private static void ValidateAlgorithmGate(string algorithm)
{
if (string.Equals(algorithm, SignatureAlgorithms.Dilithium3, StringComparison.OrdinalIgnoreCase) ||
string.Equals(algorithm, SignatureAlgorithms.Falcon512, StringComparison.OrdinalIgnoreCase))
{
if (!string.Equals(Environment.GetEnvironmentVariable("PQ_SOFT_ALLOWED"), "1", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("PQ signing requested but PQ_SOFT_ALLOWED is not enabled.");
}
}
if (string.Equals(algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase))
{
if (!string.Equals(Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED"), "1", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("SM2 signing requested but SM_SOFT_ALLOWED is not enabled.");
}
}
}
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,100 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Determinism;
using StellaOps.Signer.Core;
using System;
using System.Threading;
using System.Threading.Tasks;
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 IGuidProvider _guidProvider;
private readonly ILogger<DefaultSigningKeyResolver> _logger;
public DefaultSigningKeyResolver(
IOptions<DsseSignerOptions> options,
TimeProvider timeProvider,
ILogger<DefaultSigningKeyResolver> logger,
IGuidProvider? guidProvider = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_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}:{_guidProvider.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,70 @@
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>
/// Optional override for SM2 signing when SM_SOFT_ALLOWED=1 and profile requires SM.
/// </summary>
public string? SmAlgorithm { get; set; }
/// <summary>
/// Optional secondary algorithm for dual-signing (e.g., PQ co-sign).
/// When set, a second DSSE signature is added using the specified algorithm.
/// </summary>
public string? SecondaryAlgorithm { get; set; }
/// <summary>
/// Optional provider hint for the secondary signature.
/// </summary>
public string? SecondaryProvider { get; set; }
/// <summary>
/// Optional key identifier for the secondary signature. Falls back to the primary key id when null.
/// </summary>
public string? SecondaryKeyId { 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,68 @@
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Signing;
public sealed class HmacDsseSigner : IDsseSigner
{
private readonly IOptionsMonitor<SignerCryptoOptions> _options;
private readonly ICryptoHmac _cryptoHmac;
private readonly TimeProvider _timeProvider;
public HmacDsseSigner(
IOptionsMonitor<SignerCryptoOptions> options,
ICryptoHmac cryptoHmac,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var options = _options.CurrentValue;
var payloadBytes = SignerStatementBuilder.BuildStatementPayload(request);
var secretBytes = Convert.FromBase64String(options.Secret);
var signature = _cryptoHmac.ComputeHmacBase64ForPurpose(secretBytes, payloadBytes, HmacPurpose.Signing);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var envelope = new DsseEnvelope(
payloadBase64,
"application/vnd.in-toto+json",
new[]
{
new DsseSignature(signature, options.KeyId),
});
var metadata = new SigningMetadata(
new SigningIdentity(
options.Mode,
caller.Subject,
caller.Subject,
_timeProvider.GetUtcNow().AddMinutes(10)),
Array.Empty<string>(),
options.ProviderName,
options.AlgorithmId);
var bundle = new SigningBundle(envelope, metadata);
return ValueTask.FromResult(bundle);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Signer.Core;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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,84 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Signer.Core;
using System;
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

@@ -0,0 +1,191 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// HTTP client for Sigstore Fulcio certificate authority.
/// Supports both public Sigstore and self-hosted deployments.
/// </summary>
public sealed class FulcioHttpClient : IFulcioClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly SigstoreOptions _options;
private readonly ILogger<FulcioHttpClient> _logger;
public FulcioHttpClient(
HttpClient httpClient,
IOptions<SigstoreOptions> options,
ILogger<FulcioHttpClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient.BaseAddress = new Uri(_options.FulcioUrl.TrimEnd('/') + "/");
_httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
}
public async ValueTask<FulcioCertificateResult> GetCertificateAsync(
string identityToken,
string publicKey,
byte[] proofOfPossession,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identityToken);
ArgumentException.ThrowIfNullOrWhiteSpace(publicKey);
ArgumentNullException.ThrowIfNull(proofOfPossession);
_logger.LogDebug("Requesting signing certificate from Fulcio at {Url}", _options.FulcioUrl);
var request = new FulcioSigningCertificateRequest
{
PublicKeyRequest = new PublicKeyRequest
{
PublicKey = new PublicKeyContent
{
Algorithm = "ECDSA",
Content = publicKey
},
ProofOfPossession = Convert.ToBase64String(proofOfPossession)
}
};
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "api/v2/signingCert");
httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", identityToken);
httpRequest.Content = JsonContent.Create(request, options: JsonOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("Fulcio certificate request failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
throw new SigstoreException($"Fulcio certificate request failed: {response.StatusCode} - {errorBody}");
}
var result = await response.Content.ReadFromJsonAsync<FulcioSigningCertificateResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (result?.SignedCertificateEmbeddedSct?.Chain?.Certificates is not { Count: > 0 })
{
throw new SigstoreException("Fulcio returned empty certificate chain");
}
var certificates = result.SignedCertificateEmbeddedSct.Chain.Certificates;
var signingCert = certificates[0];
var chain = certificates.Count > 1 ? certificates.GetRange(1, certificates.Count - 1) : [];
// Parse certificate to extract metadata
var cert = X509Certificate2.CreateFromPem(signingCert);
var expiresAt = cert.NotAfter.ToUniversalTime();
// Extract OIDC claims from certificate extensions
var (subject, issuer) = ExtractOidcClaims(cert);
_logger.LogInformation(
"Obtained Fulcio certificate for subject {Subject} from issuer {Issuer}, expires {ExpiresAt}",
subject, issuer, expiresAt);
return new FulcioCertificateResult(
Certificate: signingCert,
CertificateChain: chain,
SignedCertificateTimestamp: result.SignedCertificateEmbeddedSct.Sct,
ExpiresAtUtc: new DateTimeOffset(expiresAt, TimeSpan.Zero),
Subject: subject,
Issuer: issuer);
}
private static (string Subject, string Issuer) ExtractOidcClaims(X509Certificate2 cert)
{
// Fulcio embeds OIDC claims in certificate extensions
// OID 1.3.6.1.4.1.57264.1.1 = Issuer
// OID 1.3.6.1.4.1.57264.1.7 = Subject (email or workflow identity)
var issuer = "unknown";
var subject = cert.Subject;
foreach (var ext in cert.Extensions)
{
if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
{
issuer = Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
else if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.7")
{
subject = Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
}
// Fallback to SAN email if no extension
if (subject == cert.Subject)
{
var sanExt = cert.Extensions["2.5.29.17"];
if (sanExt is X509SubjectAlternativeNameExtension san)
{
foreach (var name in san.EnumerateDnsNames())
{
subject = name;
break;
}
}
}
return (subject, issuer);
}
public void Dispose()
{
_httpClient.Dispose();
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Fulcio API DTOs
private sealed record FulcioSigningCertificateRequest
{
public PublicKeyRequest? PublicKeyRequest { get; init; }
}
private sealed record PublicKeyRequest
{
public PublicKeyContent? PublicKey { get; init; }
public string? ProofOfPossession { get; init; }
}
private sealed record PublicKeyContent
{
public string? Algorithm { get; init; }
public string? Content { get; init; }
}
private sealed record FulcioSigningCertificateResponse
{
public SignedCertificateEmbeddedSct? SignedCertificateEmbeddedSct { get; init; }
}
private sealed record SignedCertificateEmbeddedSct
{
public CertificateChain? Chain { get; init; }
public string? Sct { get; init; }
}
private sealed record CertificateChain
{
public List<string>? Certificates { get; init; }
}
}

View File

@@ -0,0 +1,100 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Client interface for Sigstore Fulcio certificate authority.
/// Obtains short-lived signing certificates using OIDC identity tokens.
/// </summary>
public interface IFulcioClient
{
/// <summary>
/// Requests a signing certificate from Fulcio using an OIDC identity token.
/// </summary>
/// <param name="identityToken">The OIDC identity token (JWT).</param>
/// <param name="publicKey">The public key (PEM format) to bind to the certificate.</param>
/// <param name="proofOfPossession">Signature proving possession of the private key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The Fulcio certificate result.</returns>
ValueTask<FulcioCertificateResult> GetCertificateAsync(
string identityToken,
string publicKey,
byte[] proofOfPossession,
CancellationToken cancellationToken);
}
/// <summary>
/// Client interface for Sigstore Rekor transparency log.
/// Uploads signatures to the append-only transparency log.
/// </summary>
public interface IRekorClient
{
/// <summary>
/// Uploads an artifact signature to the Rekor transparency log.
/// </summary>
/// <param name="artifactHash">SHA-256 hash of the artifact being signed.</param>
/// <param name="signature">The signature bytes.</param>
/// <param name="publicKey">The public key (PEM format).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The Rekor entry result with log index and inclusion proof.</returns>
ValueTask<RekorEntryResult> UploadAsync(
string artifactHash,
byte[] signature,
string publicKey,
CancellationToken cancellationToken);
/// <summary>
/// Verifies an entry exists in the Rekor log.
/// </summary>
/// <param name="uuid">The entry UUID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The entry if found, null otherwise.</returns>
ValueTask<RekorEntryResult?> GetEntryAsync(
string uuid,
CancellationToken cancellationToken);
/// <summary>
/// Searches for entries by artifact hash.
/// </summary>
/// <param name="artifactHash">SHA-256 hash of the artifact.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matching entry UUIDs.</returns>
ValueTask<string[]> SearchByHashAsync(
string artifactHash,
CancellationToken cancellationToken);
}
/// <summary>
/// Orchestrates keyless signing using Sigstore infrastructure.
/// </summary>
public interface ISigstoreSigningService
{
/// <summary>
/// Performs keyless signing of an artifact using Sigstore (Fulcio + Rekor).
/// </summary>
/// <param name="artifactBytes">The artifact bytes to sign.</param>
/// <param name="identityToken">The OIDC identity token for Fulcio.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The complete Sigstore signing result.</returns>
ValueTask<SigstoreSigningResult> SignKeylessAsync(
byte[] artifactBytes,
string identityToken,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a keyless signature against Sigstore transparency log.
/// </summary>
/// <param name="artifactBytes">The artifact bytes.</param>
/// <param name="signature">The signature to verify.</param>
/// <param name="certificate">The signing certificate (PEM).</param>
/// <param name="rekorUuid">Optional Rekor entry UUID for verification.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if signature is valid and (optionally) in Rekor.</returns>
ValueTask<bool> VerifyKeylessAsync(
byte[] artifactBytes,
byte[] signature,
string certificate,
string? rekorUuid,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,269 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// HTTP client for Sigstore Rekor transparency log.
/// Supports both public Sigstore and self-hosted deployments.
/// </summary>
public sealed class RekorHttpClient : IRekorClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly SigstoreOptions _options;
private readonly ILogger<RekorHttpClient> _logger;
public RekorHttpClient(
HttpClient httpClient,
IOptions<SigstoreOptions> options,
ILogger<RekorHttpClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient.BaseAddress = new Uri(_options.RekorUrl.TrimEnd('/') + "/");
_httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
}
public async ValueTask<RekorEntryResult> UploadAsync(
string artifactHash,
byte[] signature,
string publicKey,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactHash);
ArgumentNullException.ThrowIfNull(signature);
ArgumentException.ThrowIfNullOrWhiteSpace(publicKey);
_logger.LogDebug("Uploading signature to Rekor at {Url} for artifact hash {Hash}",
_options.RekorUrl, artifactHash[..16] + "...");
// Create hashedrekord entry type
var request = new RekorCreateEntryRequest
{
ApiVersion = "0.0.1",
Kind = "hashedrekord",
Spec = new HashedRekordSpec
{
Data = new HashedRekordData
{
Hash = new HashSpec
{
Algorithm = "sha256",
Value = artifactHash
}
},
Signature = new SignatureSpec
{
Content = Convert.ToBase64String(signature),
PublicKey = new PublicKeySpec
{
Content = Convert.ToBase64String(Encoding.UTF8.GetBytes(publicKey))
}
}
}
};
using var response = await _httpClient.PostAsJsonAsync(
"api/v1/log/entries",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("Rekor upload failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
throw new RekorException($"Rekor upload failed: {response.StatusCode} - {errorBody}");
}
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, RekorEntryResponse>>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (result is null || result.Count == 0)
{
throw new RekorException("Rekor returned empty response");
}
// Response is a dictionary with UUID as key
foreach (var (uuid, entry) in result)
{
_logger.LogInformation(
"Signature uploaded to Rekor with UUID {Uuid} at log index {LogIndex}",
uuid, entry.LogIndex);
return new RekorEntryResult(
Uuid: uuid,
LogIndex: entry.LogIndex,
IntegratedTime: entry.IntegratedTime,
LogId: entry.LogId ?? string.Empty,
InclusionProof: ParseInclusionProof(entry.Verification?.InclusionProof),
SignedEntryTimestamp: entry.Verification?.SignedEntryTimestamp);
}
throw new RekorException("Rekor returned unexpected response format");
}
public async ValueTask<RekorEntryResult?> GetEntryAsync(string uuid, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(uuid);
_logger.LogDebug("Fetching Rekor entry {Uuid}", uuid);
using var response = await _httpClient.GetAsync($"api/v1/log/entries/{uuid}", cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new RekorException($"Rekor get entry failed: {response.StatusCode} - {errorBody}");
}
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, RekorEntryResponse>>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (result is null || !result.TryGetValue(uuid, out var entry))
{
return null;
}
return new RekorEntryResult(
Uuid: uuid,
LogIndex: entry.LogIndex,
IntegratedTime: entry.IntegratedTime,
LogId: entry.LogId ?? string.Empty,
InclusionProof: ParseInclusionProof(entry.Verification?.InclusionProof),
SignedEntryTimestamp: entry.Verification?.SignedEntryTimestamp);
}
public async ValueTask<string[]> SearchByHashAsync(string artifactHash, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactHash);
_logger.LogDebug("Searching Rekor for artifact hash {Hash}", artifactHash[..16] + "...");
var request = new RekorSearchRequest
{
Hash = $"sha256:{artifactHash}"
};
using var response = await _httpClient.PostAsJsonAsync(
"api/v1/index/retrieve",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new RekorException($"Rekor search failed: {response.StatusCode} - {errorBody}");
}
var uuids = await response.Content.ReadFromJsonAsync<string[]>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return uuids ?? [];
}
private static RekorInclusionProof? ParseInclusionProof(InclusionProofResponse? proof)
{
if (proof is null)
return null;
return new RekorInclusionProof(
LogIndex: proof.LogIndex,
RootHash: proof.RootHash ?? string.Empty,
TreeSize: proof.TreeSize,
Hashes: proof.Hashes ?? []);
}
public void Dispose()
{
_httpClient.Dispose();
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Rekor API DTOs
private sealed record RekorCreateEntryRequest
{
public string? ApiVersion { get; init; }
public string? Kind { get; init; }
public HashedRekordSpec? Spec { get; init; }
}
private sealed record HashedRekordSpec
{
public HashedRekordData? Data { get; init; }
public SignatureSpec? Signature { get; init; }
}
private sealed record HashedRekordData
{
public HashSpec? Hash { get; init; }
}
private sealed record HashSpec
{
public string? Algorithm { get; init; }
public string? Value { get; init; }
}
private sealed record SignatureSpec
{
public string? Content { get; init; }
public PublicKeySpec? PublicKey { get; init; }
}
private sealed record PublicKeySpec
{
public string? Content { get; init; }
}
private sealed record RekorSearchRequest
{
public string? Hash { get; init; }
}
private sealed record RekorEntryResponse
{
public long LogIndex { get; init; }
public long IntegratedTime { get; init; }
public string? LogId { get; init; }
public VerificationResponse? Verification { get; init; }
}
private sealed record VerificationResponse
{
public InclusionProofResponse? InclusionProof { get; init; }
public string? SignedEntryTimestamp { get; init; }
}
private sealed record InclusionProofResponse
{
public long LogIndex { get; init; }
public string? RootHash { get; init; }
public long TreeSize { get; init; }
public List<string>? Hashes { get; init; }
}
}

View File

@@ -0,0 +1,30 @@
using System;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Exception thrown when Sigstore operations fail.
/// </summary>
public class SigstoreException : Exception
{
public SigstoreException(string message) : base(message) { }
public SigstoreException(string message, Exception innerException) : base(message, innerException) { }
}
/// <summary>
/// Exception thrown when Fulcio certificate request fails.
/// </summary>
public class FulcioException : SigstoreException
{
public FulcioException(string message) : base(message) { }
public FulcioException(string message, Exception innerException) : base(message, innerException) { }
}
/// <summary>
/// Exception thrown when Rekor transparency log operations fail.
/// </summary>
public class RekorException : SigstoreException
{
public RekorException(string message) : base(message) { }
public RekorException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Result of a Fulcio certificate signing request.
/// </summary>
public sealed record FulcioCertificateResult(
/// <summary>The PEM-encoded signing certificate.</summary>
string Certificate,
/// <summary>The certificate chain (intermediate + root).</summary>
IReadOnlyList<string> CertificateChain,
/// <summary>The Signed Certificate Timestamp from CT log (if available).</summary>
string? SignedCertificateTimestamp,
/// <summary>When the certificate expires.</summary>
DateTimeOffset ExpiresAtUtc,
/// <summary>The OIDC subject (email or workflow identity).</summary>
string Subject,
/// <summary>The OIDC issuer.</summary>
string Issuer);
/// <summary>
/// Result of a Rekor transparency log entry.
/// </summary>
public sealed record RekorEntryResult(
/// <summary>The Rekor log entry UUID.</summary>
string Uuid,
/// <summary>The log index number.</summary>
long LogIndex,
/// <summary>The integrated timestamp (Unix epoch).</summary>
long IntegratedTime,
/// <summary>The log ID (tree hash).</summary>
string LogId,
/// <summary>The inclusion proof for verification.</summary>
RekorInclusionProof? InclusionProof,
/// <summary>The Signed Entry Timestamp.</summary>
string? SignedEntryTimestamp);
/// <summary>
/// Merkle tree inclusion proof from Rekor.
/// </summary>
public sealed record RekorInclusionProof(
/// <summary>The log index.</summary>
long LogIndex,
/// <summary>The root hash of the tree.</summary>
string RootHash,
/// <summary>The tree size at time of inclusion.</summary>
long TreeSize,
/// <summary>The hash path from leaf to root.</summary>
IReadOnlyList<string> Hashes);
/// <summary>
/// Combined result of keyless signing with Sigstore.
/// </summary>
public sealed record SigstoreSigningResult(
/// <summary>The signature bytes (base64-encoded).</summary>
string Signature,
/// <summary>The Fulcio certificate result.</summary>
FulcioCertificateResult Certificate,
/// <summary>The Rekor entry result (if transparency logging enabled).</summary>
RekorEntryResult? RekorEntry,
/// <summary>The public key used for signing (PEM format).</summary>
string PublicKey,
/// <summary>The algorithm used.</summary>
string Algorithm);

View File

@@ -0,0 +1,87 @@
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Configuration for self-hosted Sigstore infrastructure.
/// Supports on-premise deployments with custom Fulcio/Rekor endpoints.
/// </summary>
public sealed class SigstoreOptions
{
/// <summary>
/// Section name in configuration.
/// </summary>
public const string SectionName = "Sigstore";
/// <summary>
/// Gets or sets whether Sigstore keyless signing is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the Fulcio certificate authority URL.
/// For self-hosted: e.g., "https://fulcio.internal.example.com"
/// For public Sigstore: "https://fulcio.sigstore.dev"
/// </summary>
public string FulcioUrl { get; set; } = "https://fulcio.sigstore.dev";
/// <summary>
/// Gets or sets the Rekor transparency log URL.
/// For self-hosted: e.g., "https://rekor.internal.example.com"
/// For public Sigstore: "https://rekor.sigstore.dev"
/// </summary>
public string RekorUrl { get; set; } = "https://rekor.sigstore.dev";
/// <summary>
/// Gets or sets the OIDC issuer URL for identity tokens.
/// For self-hosted: e.g., "https://keycloak.internal.example.com/realms/stellaops"
/// For public: "https://oauth2.sigstore.dev/auth"
/// </summary>
public string OidcIssuer { get; set; } = "https://oauth2.sigstore.dev/auth";
/// <summary>
/// Gets or sets the OIDC client ID for token exchange.
/// </summary>
public string OidcClientId { get; set; } = "sigstore";
/// <summary>
/// Gets or sets the OIDC audience for token validation.
/// </summary>
public string OidcAudience { get; set; } = "sigstore";
/// <summary>
/// Gets or sets the path to custom CA certificate bundle for self-hosted TLS.
/// When null, system certificates are used.
/// </summary>
public string? CaBundlePath { get; set; }
/// <summary>
/// Gets or sets whether to skip TLS verification (NOT recommended for production).
/// </summary>
public bool InsecureSkipVerify { get; set; }
/// <summary>
/// Gets or sets the timeout for Sigstore API calls in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets whether to require Rekor transparency log entry.
/// When true, signing fails if Rekor upload fails.
/// </summary>
public bool RequireRekorEntry { get; set; } = true;
/// <summary>
/// Gets or sets whether to embed the Signed Certificate Timestamp (SCT) in signatures.
/// </summary>
public bool EmbedSct { get; set; } = true;
/// <summary>
/// Gets or sets fallback to key-based signing if OIDC is unavailable.
/// </summary>
public bool FallbackToKeyBased { get; set; } = true;
/// <summary>
/// Gets or sets the certificate validity duration in minutes.
/// Fulcio issues short-lived certificates; default is 10 minutes.
/// </summary>
public int CertificateValidityMinutes { get; set; } = 10;
}

View File

@@ -0,0 +1,84 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Extension methods for registering Sigstore services.
/// </summary>
public static class SigstoreServiceCollectionExtensions
{
/// <summary>
/// Adds self-hosted Sigstore services (Fulcio + Rekor) for keyless signing.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Configuration containing Sigstore settings.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSigstoreKeylessSigning(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration
services.Configure<SigstoreOptions>(configuration.GetSection(SigstoreOptions.SectionName));
// Register Fulcio client with custom HttpClient
services.AddHttpClient<IFulcioClient, FulcioHttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SigstoreOptions>>().Value;
client.BaseAddress = new Uri(options.FulcioUrl.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<SigstoreOptions>>().Value;
return CreateHttpHandler(options);
});
// Register Rekor client with custom HttpClient
services.AddHttpClient<IRekorClient, RekorHttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SigstoreOptions>>().Value;
client.BaseAddress = new Uri(options.RekorUrl.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<SigstoreOptions>>().Value;
return CreateHttpHandler(options);
});
// Register orchestrating service
services.AddSingleton<ISigstoreSigningService, SigstoreSigningService>();
return services;
}
/// <summary>
/// Creates HTTP handler with custom CA bundle support for self-hosted deployments.
/// </summary>
private static HttpMessageHandler CreateHttpHandler(SigstoreOptions options)
{
var handler = new HttpClientHandler();
// Configure custom CA bundle for self-hosted TLS
if (!string.IsNullOrEmpty(options.CaBundlePath))
{
var customCert = X509Certificate2.CreateFromPemFile(options.CaBundlePath);
handler.ClientCertificates.Add(customCert);
}
// Allow insecure for development (NOT for production)
if (options.InsecureSkipVerify)
{
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
return handler;
}
}

View File

@@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Infrastructure.Sigstore;
/// <summary>
/// Orchestrates keyless signing using self-hosted Sigstore infrastructure.
/// Coordinates Fulcio (certificates) and Rekor (transparency) for complete keyless signing.
/// </summary>
public sealed class SigstoreSigningService : ISigstoreSigningService
{
private readonly IFulcioClient _fulcioClient;
private readonly IRekorClient _rekorClient;
private readonly SigstoreOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SigstoreSigningService> _logger;
public SigstoreSigningService(
IFulcioClient fulcioClient,
IRekorClient rekorClient,
IOptions<SigstoreOptions> options,
ILogger<SigstoreSigningService> logger,
TimeProvider? timeProvider = null)
{
_fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient));
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<SigstoreSigningResult> SignKeylessAsync(
byte[] artifactBytes,
string identityToken,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifactBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(identityToken);
_logger.LogInformation("Starting Sigstore keyless signing for artifact of {Size} bytes", artifactBytes.Length);
// 1. Generate ephemeral key pair
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(ecdsa);
// 2. Compute artifact hash
var artifactHash = SHA256.HashData(artifactBytes);
var artifactHashHex = Convert.ToHexString(artifactHash).ToLowerInvariant();
// 3. Create proof of possession (sign the OIDC identity token)
var tokenBytes = Encoding.UTF8.GetBytes(identityToken);
var proofOfPossession = ecdsa.SignData(tokenBytes, HashAlgorithmName.SHA256);
// 4. Request certificate from Fulcio
_logger.LogDebug("Requesting signing certificate from Fulcio");
var certificate = await _fulcioClient.GetCertificateAsync(
identityToken,
publicKeyPem,
proofOfPossession,
cancellationToken).ConfigureAwait(false);
// 5. Sign the artifact
var signature = ecdsa.SignData(artifactBytes, HashAlgorithmName.SHA256);
var signatureBase64 = Convert.ToBase64String(signature);
_logger.LogDebug("Artifact signed with ephemeral key");
// 6. Upload to Rekor transparency log (if required)
RekorEntryResult? rekorEntry = null;
if (_options.RequireRekorEntry)
{
_logger.LogDebug("Uploading signature to Rekor transparency log");
try
{
rekorEntry = await _rekorClient.UploadAsync(
artifactHashHex,
signature,
publicKeyPem,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Signature recorded in Rekor at log index {LogIndex} with UUID {Uuid}",
rekorEntry.LogIndex, rekorEntry.Uuid);
}
catch (RekorException ex) when (!_options.RequireRekorEntry)
{
_logger.LogWarning(ex, "Rekor upload failed but not required; continuing without transparency entry");
}
}
return new SigstoreSigningResult(
Signature: signatureBase64,
Certificate: certificate,
RekorEntry: rekorEntry,
PublicKey: publicKeyPem,
Algorithm: "ES256");
}
public async ValueTask<bool> VerifyKeylessAsync(
byte[] artifactBytes,
byte[] signature,
string certificate,
string? rekorUuid,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifactBytes);
ArgumentNullException.ThrowIfNull(signature);
ArgumentException.ThrowIfNullOrWhiteSpace(certificate);
_logger.LogDebug("Verifying keyless signature");
try
{
// 1. Parse certificate and extract public key
using var cert = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificate);
using var ecdsa = cert.GetECDsaPublicKey();
if (ecdsa is null)
{
_logger.LogWarning("Certificate does not contain ECDSA public key");
return false;
}
// 2. Verify signature
var isValidSignature = ecdsa.VerifyData(artifactBytes, signature, HashAlgorithmName.SHA256);
if (!isValidSignature)
{
_logger.LogWarning("Signature verification failed");
return false;
}
// 3. Check certificate validity
var now = _timeProvider.GetUtcNow();
if (now < cert.NotBefore || now > cert.NotAfter)
{
_logger.LogWarning(
"Certificate expired or not yet valid. NotBefore={NotBefore}, NotAfter={NotAfter}, Now={Now}",
cert.NotBefore, cert.NotAfter, now);
// Note: For keyless signing, certificate expiry at verification time is expected
// The important thing is that the certificate was valid at signing time
// This is proven by the Rekor entry timestamp
}
// 4. Verify Rekor entry if UUID provided
if (!string.IsNullOrEmpty(rekorUuid))
{
var entry = await _rekorClient.GetEntryAsync(rekorUuid, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
_logger.LogWarning("Rekor entry {Uuid} not found", rekorUuid);
return false;
}
// Verify the entry timestamp is within certificate validity period
var entryTime = DateTimeOffset.FromUnixTimeSeconds(entry.IntegratedTime);
if (entryTime < cert.NotBefore || entryTime > cert.NotAfter)
{
_logger.LogWarning(
"Rekor entry timestamp {EntryTime} is outside certificate validity window",
entryTime);
return false;
}
_logger.LogDebug("Rekor entry verified at log index {LogIndex}", entry.LogIndex);
}
_logger.LogInformation("Keyless signature verification successful");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Keyless signature verification failed with exception");
return false;
}
}
private static string ExportPublicKeyPem(ECDsa ecdsa)
{
var publicKeyBytes = ecdsa.ExportSubjectPublicKeyInfo();
var base64 = Convert.ToBase64String(publicKeyBytes);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN PUBLIC KEY-----");
for (int i = 0; i < base64.Length; i += 64)
{
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
sb.AppendLine("-----END PUBLIC KEY-----");
return sb.ToString();
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.Signer.Infrastructure Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,411 @@
// -----------------------------------------------------------------------------
// SignerAuthTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-012 - Add auth tests: verify signing requires elevated permissions; unauthorized requests denied
// Description: Authentication and authorization tests for Signer WebService
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Signer.Tests.Auth;
/// <summary>
/// Authentication and authorization tests for Signer WebService.
/// Validates:
/// - Signing requires elevated permissions
/// - Unauthorized requests are denied
/// - Token validation (missing, invalid, expired)
/// - DPoP proof requirements
/// </summary>
[Trait("Category", "Auth")]
[Trait("Category", "Security")]
[Trait("Category", "W1")]
public sealed class SignerAuthTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ITestOutputHelper _output;
public SignerAuthTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
#region Missing Token Tests
[Fact]
public async Task SignDsse_NoAuthHeader_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var content = JsonContent.Create(CreateBasicSignRequest());
// Act - no authorization header
var response = await client.PostAsync("/api/v1/signer/sign/dsse", content);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ No auth header → 401/403");
}
[Fact]
public async Task VerifyDsse_NoAuthHeader_MayBeAllowed()
{
// Arrange
var client = _factory.CreateClient();
var content = JsonContent.Create(new { bundle = new { } });
// Act - verification may have different auth requirements than signing
var response = await client.PostAsync("/api/v1/signer/verify/dsse", content);
// Assert - verify might be less restricted than sign
_output.WriteLine($"✓ Verify without auth → {response.StatusCode}");
// If 404, endpoint doesn't exist (skip)
if (response.StatusCode == HttpStatusCode.NotFound)
{
_output.WriteLine(" (verify endpoint not found)");
return;
}
// Document the auth requirement
var requiresAuth = response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden;
_output.WriteLine($" Requires auth: {requiresAuth}");
}
#endregion
#region Invalid Token Tests
[Fact]
public async Task SignDsse_EmptyBearerToken_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ Empty bearer token → 401/403");
}
[Fact]
public async Task SignDsse_MalformedBearerToken_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not.a.valid.jwt");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ Malformed bearer token → 401/403");
}
[Fact]
public async Task SignDsse_WrongAuthScheme_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", "dXNlcjpwYXNz"); // user:pass
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ Wrong auth scheme (Basic) → 401/403");
}
[Fact]
public async Task SignDsse_RandomStringToken_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Guid.NewGuid().ToString());
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ Random string token → 401/403");
}
#endregion
#region DPoP Tests
[Fact]
public async Task SignDsse_MissingDPoP_MayBeRequired()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Note: NOT adding DPoP header
// Act
var response = await client.SendAsync(request);
// Assert - DPoP may or may not be required
_output.WriteLine($"✓ Without DPoP → {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.Forbidden)
{
_output.WriteLine(" DPoP appears to be required for signing");
}
}
[Fact]
public async Task SignDsse_MalformedDPoP_Returns401()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "invalid-dpop-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden,
HttpStatusCode.BadRequest);
_output.WriteLine($"✓ Malformed DPoP → {response.StatusCode}");
}
#endregion
#region Permission Tests
[Fact]
public async Task SignDsse_RequiresElevatedPermissions()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
// Use a stub token that passes validation but lacks signing permissions
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-readonly-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - signing should require specific permissions
if (response.StatusCode == HttpStatusCode.Forbidden)
{
_output.WriteLine("✓ Signing requires elevated permissions (403 Forbidden)");
}
else
{
_output.WriteLine($" Response: {response.StatusCode} (stub token behavior)");
}
}
#endregion
#region Security Header Tests
[Fact]
public async Task Response_ShouldNotExposeSensitiveHeaders()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - should not expose internal details
response.Headers.Should().NotContainKey("X-Powered-By");
response.Headers.Should().NotContainKey("Server"); // If present, should not expose version
_output.WriteLine("✓ Response does not expose sensitive headers");
}
[Fact]
public async Task Error_ShouldNotExposeStackTrace()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new { invalid = true })
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
var content = await response.Content.ReadAsStringAsync();
content.Should().NotContain("System.Exception");
content.Should().NotContain("at StellaOps.");
content.Should().NotContain("StackTrace");
_output.WriteLine("✓ Error response does not expose stack trace");
}
#endregion
#region Injection Attack Tests
[Theory]
[InlineData("' OR '1'='1")]
[InlineData("'; DROP TABLE users; --")]
[InlineData("<script>alert('xss')</script>")]
[InlineData("{{7*7}}")]
[InlineData("${7*7}")]
public async Task SignDsse_InjectionInAuth_HandledSafely(string maliciousValue)
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", maliciousValue);
// Act
var response = await client.SendAsync(request);
// Assert - should reject, not execute
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden,
HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
content.Should().NotContain("49"); // 7*7 result
content.Should().NotContain("<script>");
_output.WriteLine($"✓ Injection '{maliciousValue[..Math.Min(20, maliciousValue.Length)]}...' handled safely");
}
#endregion
#region Token Replay Tests
[Fact]
public async Task SignDsse_TokenReplay_ShouldBeDetectable()
{
// Note: This tests the infrastructure for replay detection
// Actual replay detection depends on DPoP nonce or token tracking
var client = _factory.CreateClient();
var request1 = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request1.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request1.Headers.Add("DPoP", "stub-proof-1");
var request2 = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request2.Headers.Add("DPoP", "stub-proof-1"); // Same proof
// Act
var response1 = await client.SendAsync(request1);
var response2 = await client.SendAsync(request2);
// Assert - at minimum, document the behavior
_output.WriteLine($"✓ First request: {response1.StatusCode}");
_output.WriteLine($"✓ Second request (replay): {response2.StatusCode}");
// If replay detection is active, second should fail
if (response1.IsSuccessStatusCode && !response2.IsSuccessStatusCode)
{
_output.WriteLine(" Replay detection appears active");
}
}
#endregion
#region Helper Methods
private static object CreateBasicSignRequest()
{
return new
{
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" }
};
}
#endregion
}

View File

@@ -0,0 +1,702 @@
// -----------------------------------------------------------------------------
// PluginAvailabilityTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-017 - Add plugin availability tests: plugin unavailable → graceful degradation or clear error
// Description: Tests for plugin availability detection and graceful degradation
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Xunit;
namespace StellaOps.Signer.Tests.Availability;
/// <summary>
/// Tests for crypto plugin availability and graceful degradation.
/// Validates:
/// - Unavailable plugins return clear error codes
/// - Fallback to alternative plugins works when configured
/// - Plugin health checks report accurate status
/// - Error messages are deterministic and actionable
/// </summary>
[Trait("Category", "Availability")]
[Trait("Category", "GracefulDegradation")]
[Trait("Category", "Plugin")]
public sealed class PluginAvailabilityTests
{
private readonly ITestOutputHelper _output;
// Error codes for plugin availability
private const string PluginUnavailableCode = "SIGNER_PLUGIN_UNAVAILABLE";
private const string AlgorithmUnsupportedCode = "SIGNER_ALGORITHM_UNSUPPORTED";
private const string FallbackUsedCode = "SIGNER_FALLBACK_USED";
private const string NoPluginAvailableCode = "SIGNER_NO_PLUGIN_AVAILABLE";
public PluginAvailabilityTests(ITestOutputHelper output)
{
_output = output;
}
#region Plugin Unavailable Tests
[Fact]
public void UnavailablePlugin_ReturnsPluginUnavailableError()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("CryptoPro", "GOST_R3410_2012_256"));
// Act
var result = registry.TrySign("GOST_R3410_2012_256", CreateTestPayload());
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().Be(PluginUnavailableCode);
result.ErrorMessage.Should().Contain("CryptoPro");
result.ErrorMessage.Should().Contain("unavailable");
_output.WriteLine($"Error code: {result.ErrorCode}");
_output.WriteLine($"Error message: {result.ErrorMessage}");
}
[Fact]
public void UnavailablePlugin_ErrorMessageIsActionable()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("HSM-PKCS11", "ES256",
"HSM connection failed: Connection refused"));
// Act
var result = registry.TrySign("ES256", CreateTestPayload());
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("HSM");
result.ErrorMessage.Should().Contain("Connection refused");
// Error should suggest remediation
result.Remediation.Should().NotBeNullOrEmpty();
_output.WriteLine($"Error: {result.ErrorMessage}");
_output.WriteLine($"Remediation: {result.Remediation}");
}
[Fact]
public void UnavailablePlugin_ErrorCodeIsDeterministic()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("TestPlugin", "TestAlgorithm"));
// Act - call multiple times
var results = Enumerable.Range(0, 5)
.Select(_ => registry.TrySign("TestAlgorithm", CreateTestPayload()))
.ToList();
// Assert - all error codes should be identical
results.Should().AllSatisfy(r =>
{
r.ErrorCode.Should().Be(PluginUnavailableCode);
});
_output.WriteLine("Deterministic error code verified across 5 calls");
}
#endregion
#region Algorithm Unsupported Tests
[Fact]
public void UnsupportedAlgorithm_ReturnsAlgorithmUnsupportedError()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("BouncyCastle", new[] { "Ed25519", "ES256" }));
// Act
var result = registry.TrySign("GOST_R3410_2012_256", CreateTestPayload());
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().Be(AlgorithmUnsupportedCode);
result.ErrorMessage.Should().Contain("GOST_R3410_2012_256");
_output.WriteLine($"Error: {result.ErrorMessage}");
}
[Fact]
public void UnsupportedAlgorithm_ListsAvailableAlternatives()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("BouncyCastle", new[] { "Ed25519", "ES256", "RS256" }));
// Act
var result = registry.TrySign("SM2", CreateTestPayload());
// Assert
result.Success.Should().BeFalse();
result.AvailableAlgorithms.Should().Contain("Ed25519");
result.AvailableAlgorithms.Should().Contain("ES256");
result.AvailableAlgorithms.Should().Contain("RS256");
_output.WriteLine($"Available alternatives: {string.Join(", ", result.AvailableAlgorithms)}");
}
#endregion
#region Fallback Plugin Tests
[Fact]
public void UnavailablePrimaryPlugin_FallbackToSecondary()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("CryptoPro-HSM", "ES256"), priority: 1);
registry.RegisterPlugin(new AvailablePlugin("BouncyCastle-Software", new[] { "ES256" }), priority: 2);
registry.EnableFallback = true;
// Act
var result = registry.TrySign("ES256", CreateTestPayload());
// Assert
result.Success.Should().BeTrue("fallback plugin should succeed");
result.UsedPlugin.Should().Be("BouncyCastle-Software");
result.WasFallback.Should().BeTrue();
_output.WriteLine($"Primary unavailable, used fallback: {result.UsedPlugin}");
}
[Fact]
public void FallbackUsed_IncludesWarningCode()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("PreferredPlugin", "Ed25519"), priority: 1);
registry.RegisterPlugin(new AvailablePlugin("FallbackPlugin", new[] { "Ed25519" }), priority: 2);
registry.EnableFallback = true;
// Act
var result = registry.TrySign("Ed25519", CreateTestPayload());
// Assert
result.Success.Should().BeTrue();
result.WarningCode.Should().Be(FallbackUsedCode);
result.WarningMessage.Should().Contain("fallback");
_output.WriteLine($"Warning: {result.WarningCode} - {result.WarningMessage}");
}
[Fact]
public void FallbackDisabled_NoFallbackAttempted()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("PrimaryPlugin", "Ed25519"), priority: 1);
registry.RegisterPlugin(new AvailablePlugin("FallbackPlugin", new[] { "Ed25519" }), priority: 2);
registry.EnableFallback = false; // Disabled
// Act
var result = registry.TrySign("Ed25519", CreateTestPayload());
// Assert
result.Success.Should().BeFalse("fallback is disabled");
result.ErrorCode.Should().Be(PluginUnavailableCode);
_output.WriteLine("Fallback disabled - failed as expected");
}
[Fact]
public void AllPluginsUnavailable_ReturnsNoPluginAvailableError()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("Plugin1", "Ed25519"));
registry.RegisterPlugin(new UnavailablePlugin("Plugin2", "Ed25519"));
registry.RegisterPlugin(new UnavailablePlugin("Plugin3", "Ed25519"));
registry.EnableFallback = true;
// Act
var result = registry.TrySign("Ed25519", CreateTestPayload());
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().Be(NoPluginAvailableCode);
result.ErrorMessage.Should().ContainEquivalentOf("no plugin available");
_output.WriteLine($"All plugins unavailable: {result.ErrorMessage}");
}
#endregion
#region Plugin Health Check Tests
[Fact]
public void PluginHealthCheck_ReportsAccurateStatus()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("HealthyPlugin", new[] { "Ed25519" }));
registry.RegisterPlugin(new UnavailablePlugin("UnhealthyPlugin", "GOST"));
// Act
var healthReport = registry.GetHealthReport();
// Assert
_output.WriteLine("=== Plugin Health Report ===");
foreach (var plugin in healthReport.Plugins)
{
var status = plugin.IsHealthy ? "✓ Healthy" : "✗ Unhealthy";
_output.WriteLine($" {plugin.Name}: {status}");
if (!plugin.IsHealthy)
{
_output.WriteLine($" Reason: {plugin.HealthCheckError}");
}
}
healthReport.Plugins.Should().Contain(p => p.Name == "HealthyPlugin" && p.IsHealthy);
healthReport.Plugins.Should().Contain(p => p.Name == "UnhealthyPlugin" && !p.IsHealthy);
}
[Fact]
public void PluginHealthCheck_IncludesLastCheckTime()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("TestPlugin", new[] { "Ed25519" }));
// Act
var healthReport = registry.GetHealthReport();
// Assert
healthReport.CheckedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
healthReport.Plugins.Should().AllSatisfy(p =>
p.LastChecked.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)));
_output.WriteLine($"Health check timestamp: {healthReport.CheckedAt:O}");
}
[Fact]
public void PluginHealthCheck_ListsCapabilities()
{
// Arrange
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("MultiCapPlugin",
new[] { "Ed25519", "ES256", "ES384", "RS256" }));
// Act
var healthReport = registry.GetHealthReport();
var plugin = healthReport.Plugins.First(p => p.Name == "MultiCapPlugin");
// Assert
plugin.SupportedAlgorithms.Should().HaveCount(4);
plugin.SupportedAlgorithms.Should().Contain("Ed25519");
plugin.SupportedAlgorithms.Should().Contain("ES256");
_output.WriteLine($"Capabilities: {string.Join(", ", plugin.SupportedAlgorithms)}");
}
#endregion
#region Degraded Mode Tests
[Fact]
public void DegradedMode_PartialFunctionality()
{
// Arrange - some plugins available, some not
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("BouncyCastle", new[] { "Ed25519", "ES256" }));
registry.RegisterPlugin(new UnavailablePlugin("CryptoPro", "GOST_R3410_2012_256"));
registry.RegisterPlugin(new UnavailablePlugin("SimRemote", "SM2"));
// Act
var status = registry.GetServiceStatus();
// Assert
status.Mode.Should().Be(ServiceMode.Degraded);
status.AvailableAlgorithms.Should().Contain("Ed25519");
status.AvailableAlgorithms.Should().Contain("ES256");
status.UnavailableAlgorithms.Should().Contain("GOST_R3410_2012_256");
status.UnavailableAlgorithms.Should().Contain("SM2");
_output.WriteLine($"Service mode: {status.Mode}");
_output.WriteLine($"Available: {string.Join(", ", status.AvailableAlgorithms)}");
_output.WriteLine($"Unavailable: {string.Join(", ", status.UnavailableAlgorithms)}");
}
[Fact]
public void FullyDegraded_ReturnsServiceUnavailable()
{
// Arrange - all plugins unavailable
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new UnavailablePlugin("Plugin1", "Ed25519"));
registry.RegisterPlugin(new UnavailablePlugin("Plugin2", "ES256"));
// Act
var status = registry.GetServiceStatus();
// Assert
status.Mode.Should().Be(ServiceMode.Unavailable);
status.AvailableAlgorithms.Should().BeEmpty();
_output.WriteLine($"Service mode: {status.Mode}");
}
[Fact]
public void FullyHealthy_ReturnsOperational()
{
// Arrange - all plugins available
var registry = new TestPluginRegistry();
registry.RegisterPlugin(new AvailablePlugin("Plugin1", new[] { "Ed25519" }));
registry.RegisterPlugin(new AvailablePlugin("Plugin2", new[] { "ES256" }));
// Act
var status = registry.GetServiceStatus();
// Assert
status.Mode.Should().Be(ServiceMode.Operational);
_output.WriteLine($"Service mode: {status.Mode}");
}
#endregion
#region Transient Failure Tests
[Fact]
public void TransientFailure_RetrySucceeds()
{
// Arrange
var plugin = new TransientFailurePlugin("FlakeyPlugin", "Ed25519", failCount: 2);
var registry = new TestPluginRegistry();
registry.RegisterPlugin(plugin);
registry.RetryCount = 3;
// Act
var result = registry.TrySignWithRetry("Ed25519", CreateTestPayload());
// Assert
result.Success.Should().BeTrue("should succeed after retries");
result.RetryCount.Should().Be(2, "should have retried twice before success");
_output.WriteLine($"Succeeded after {result.RetryCount} retries");
}
[Fact]
public void TransientFailure_ExceedsRetryLimit_Fails()
{
// Arrange
var plugin = new TransientFailurePlugin("FlakeyPlugin", "Ed25519", failCount: 5);
var registry = new TestPluginRegistry();
registry.RegisterPlugin(plugin);
registry.RetryCount = 3;
// Act
var result = registry.TrySignWithRetry("Ed25519", CreateTestPayload());
// Assert
result.Success.Should().BeFalse("should fail after exhausting retries");
result.RetryCount.Should().Be(3);
result.ErrorMessage.Should().Contain("exhausted");
_output.WriteLine($"Failed after {result.RetryCount} retries");
}
#endregion
#region Helper Methods
private static byte[] CreateTestPayload()
{
return Encoding.UTF8.GetBytes("{\"test\":\"payload\"}");
}
#endregion
#region Test Infrastructure
private enum ServiceMode { Operational, Degraded, Unavailable }
private record SignResult(
bool Success,
byte[]? Signature = null,
string ErrorCode = "",
string ErrorMessage = "",
string Remediation = "",
string WarningCode = "",
string WarningMessage = "",
string UsedPlugin = "",
bool WasFallback = false,
int RetryCount = 0,
IReadOnlyList<string>? AvailableAlgorithms = null);
private record HealthReport(
DateTime CheckedAt,
IReadOnlyList<PluginHealth> Plugins);
private record PluginHealth(
string Name,
bool IsHealthy,
string HealthCheckError,
DateTime LastChecked,
IReadOnlyList<string> SupportedAlgorithms);
private record ServiceStatus(
ServiceMode Mode,
IReadOnlyList<string> AvailableAlgorithms,
IReadOnlyList<string> UnavailableAlgorithms);
private interface ITestPlugin
{
string Name { get; }
bool IsAvailable { get; }
string AvailabilityError { get; }
IReadOnlyList<string> SupportedAlgorithms { get; }
byte[] Sign(byte[] payload);
}
private sealed class AvailablePlugin : ITestPlugin
{
private readonly byte[] _key;
public AvailablePlugin(string name, string[] algorithms)
{
Name = name;
SupportedAlgorithms = algorithms;
_key = SHA256.HashData(Encoding.UTF8.GetBytes(name));
}
public string Name { get; }
public bool IsAvailable => true;
public string AvailabilityError => "";
public IReadOnlyList<string> SupportedAlgorithms { get; }
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA256(_key);
return hmac.ComputeHash(payload);
}
}
private sealed class UnavailablePlugin : ITestPlugin
{
public UnavailablePlugin(string name, string algorithm, string error = "Plugin unavailable")
{
Name = name;
SupportedAlgorithms = new[] { algorithm };
AvailabilityError = error;
}
public string Name { get; }
public bool IsAvailable => false;
public string AvailabilityError { get; }
public IReadOnlyList<string> SupportedAlgorithms { get; }
public byte[] Sign(byte[] payload)
{
throw new InvalidOperationException(AvailabilityError);
}
}
private sealed class TransientFailurePlugin : ITestPlugin
{
private readonly byte[] _key;
private int _failuresRemaining;
public TransientFailurePlugin(string name, string algorithm, int failCount)
{
Name = name;
SupportedAlgorithms = new[] { algorithm };
_failuresRemaining = failCount;
_key = SHA256.HashData(Encoding.UTF8.GetBytes(name));
}
public string Name { get; }
public bool IsAvailable => true;
public string AvailabilityError => "";
public IReadOnlyList<string> SupportedAlgorithms { get; }
public byte[] Sign(byte[] payload)
{
if (_failuresRemaining > 0)
{
_failuresRemaining--;
throw new InvalidOperationException("Transient failure");
}
using var hmac = new HMACSHA256(_key);
return hmac.ComputeHash(payload);
}
}
private sealed class TestPluginRegistry
{
private readonly List<(ITestPlugin Plugin, int Priority)> _plugins = new();
public bool EnableFallback { get; set; } = false;
public int RetryCount { get; set; } = 0;
public void RegisterPlugin(ITestPlugin plugin, int priority = 0)
{
_plugins.Add((plugin, priority));
}
public SignResult TrySign(string algorithm, byte[] payload)
{
var availableAlgorithms = _plugins
.Where(p => p.Plugin.IsAvailable)
.SelectMany(p => p.Plugin.SupportedAlgorithms)
.Distinct()
.ToList();
var candidates = _plugins
.Where(p => p.Plugin.SupportedAlgorithms.Contains(algorithm))
.OrderBy(p => p.Priority)
.ToList();
if (!candidates.Any())
{
return new SignResult(
Success: false,
ErrorCode: AlgorithmUnsupportedCode,
ErrorMessage: $"Algorithm '{algorithm}' not supported by any registered plugin",
AvailableAlgorithms: availableAlgorithms);
}
foreach (var (plugin, _) in candidates)
{
if (!plugin.IsAvailable)
{
if (!EnableFallback)
{
return new SignResult(
Success: false,
ErrorCode: PluginUnavailableCode,
ErrorMessage: $"Plugin '{plugin.Name}' unavailable: {plugin.AvailabilityError}",
Remediation: "Check plugin configuration and connectivity");
}
continue; // Try fallback
}
try
{
var signature = plugin.Sign(payload);
var wasFallback = candidates.First().Plugin != plugin;
return new SignResult(
Success: true,
Signature: signature,
UsedPlugin: plugin.Name,
WasFallback: wasFallback,
WarningCode: wasFallback ? FallbackUsedCode : "",
WarningMessage: wasFallback ? $"Using fallback plugin {plugin.Name}" : "",
AvailableAlgorithms: availableAlgorithms);
}
catch (Exception ex)
{
if (!EnableFallback)
{
return new SignResult(
Success: false,
ErrorCode: PluginUnavailableCode,
ErrorMessage: $"Plugin '{plugin.Name}' failed: {ex.Message}",
AvailableAlgorithms: availableAlgorithms);
}
}
}
return new SignResult(
Success: false,
ErrorCode: NoPluginAvailableCode,
ErrorMessage: $"No plugin available for algorithm '{algorithm}'",
AvailableAlgorithms: availableAlgorithms);
}
public SignResult TrySignWithRetry(string algorithm, byte[] payload)
{
var retries = 0;
var candidates = _plugins
.Where(p => p.Plugin.SupportedAlgorithms.Contains(algorithm))
.OrderBy(p => p.Priority)
.ToList();
if (!candidates.Any())
{
return new SignResult(
Success: false,
ErrorCode: AlgorithmUnsupportedCode,
ErrorMessage: $"Algorithm '{algorithm}' not supported");
}
var plugin = candidates.First().Plugin;
while (true)
{
try
{
var signature = plugin.Sign(payload);
return new SignResult(
Success: true,
Signature: signature,
UsedPlugin: plugin.Name,
RetryCount: retries);
}
catch
{
if (retries >= RetryCount)
{
break;
}
retries++;
}
}
return new SignResult(
Success: false,
ErrorCode: PluginUnavailableCode,
ErrorMessage: $"Retries exhausted after {retries} retries",
RetryCount: retries);
}
public HealthReport GetHealthReport()
{
var now = DateTime.UtcNow;
var pluginHealths = _plugins.Select(p => new PluginHealth(
Name: p.Plugin.Name,
IsHealthy: p.Plugin.IsAvailable,
HealthCheckError: p.Plugin.AvailabilityError,
LastChecked: now,
SupportedAlgorithms: p.Plugin.SupportedAlgorithms.ToList()
)).ToList();
return new HealthReport(now, pluginHealths);
}
public ServiceStatus GetServiceStatus()
{
var available = _plugins
.Where(p => p.Plugin.IsAvailable)
.SelectMany(p => p.Plugin.SupportedAlgorithms)
.Distinct()
.ToList();
var unavailable = _plugins
.Where(p => !p.Plugin.IsAvailable)
.SelectMany(p => p.Plugin.SupportedAlgorithms)
.Except(available)
.Distinct()
.ToList();
var mode = available.Any()
? (unavailable.Any() ? ServiceMode.Degraded : ServiceMode.Operational)
: ServiceMode.Unavailable;
return new ServiceStatus(mode, available, unavailable);
}
}
#endregion
}

View File

@@ -0,0 +1,416 @@
// -----------------------------------------------------------------------------
// CeremonyOrchestratorIntegrationTests.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Task: DUAL-012
// Description: Integration tests for multi-approver ceremony workflows.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.Core.Ceremonies;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Signer.Tests.Ceremonies;
/// <summary>
/// Integration tests for dual-control ceremony workflows.
/// Tests full ceremony lifecycle including multi-approver scenarios.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class CeremonyOrchestratorIntegrationTests : IAsyncLifetime
{
private readonly ICeremonyRepository _mockRepository;
private readonly ICeremonyAuditSink _mockAuditSink;
private readonly ICeremonyApproverValidator _mockApproverValidator;
private readonly MockTimeProvider _mockTimeProvider;
private readonly CeremonyOrchestrator _orchestrator;
private readonly Dictionary<Guid, Ceremony> _ceremoniesStore;
private readonly List<CeremonyAuditEvent> _auditEvents;
public CeremonyOrchestratorIntegrationTests()
{
_mockRepository = Substitute.For<ICeremonyRepository>();
_mockAuditSink = Substitute.For<ICeremonyAuditSink>();
_mockApproverValidator = Substitute.For<ICeremonyApproverValidator>();
_mockTimeProvider = new MockTimeProvider();
_ceremoniesStore = new Dictionary<Guid, Ceremony>();
_auditEvents = new List<CeremonyAuditEvent>();
var options = Options.Create(new CeremonyOptions
{
Enabled = true,
DefaultThreshold = 2,
ExpirationMinutes = 60
});
var logger = Substitute.For<ILogger<CeremonyOrchestrator>>();
SetupRepositoryMock();
SetupAuditSinkMock();
SetupApproverValidatorMock();
_orchestrator = new CeremonyOrchestrator(
_mockRepository,
_mockAuditSink,
_mockApproverValidator,
_mockTimeProvider,
options,
logger);
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
#region Creation Tests
[Fact]
public async Task CreateCeremony_WithValidRequest_Succeeds()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload
{
KeyId = "signing-key-001",
Reason = "Scheduled rotation"
},
ThresholdOverride = 2
};
// Act
var result = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
// Assert
result.Success.Should().BeTrue();
result.Ceremony.Should().NotBeNull();
result.Ceremony!.State.Should().Be(CeremonyState.Pending);
result.Ceremony.OperationType.Should().Be(CeremonyOperationType.KeyRotation);
}
[Fact]
public async Task CreateCeremony_WithSingleApprover_ApprovedImmediately()
{
// Arrange - threshold of 1
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload
{
KeyId = "minor-key",
Reason = "Minor rotation"
},
ThresholdOverride = 1
};
// Create
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
createResult.Success.Should().BeTrue();
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Single approval should immediately move to Approved
var approvalResult = await _orchestrator.ApproveCeremonyAsync(
new ApproveCeremonyRequest
{
CeremonyId = ceremonyId,
ApprovalSignature = Convert.FromBase64String("c2lnbmF0dXJl"),
ApprovalReason = "Approved"
},
"approver@example.com");
approvalResult.Success.Should().BeTrue();
approvalResult.Ceremony!.State.Should().Be(CeremonyState.Approved);
}
#endregion
#region Approval Tests
[Fact]
public async Task ApproveCeremony_DuplicateApprover_IsRejected()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload { KeyId = "key-001" },
ThresholdOverride = 2
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// First approval succeeds
var approval1 = await _orchestrator.ApproveCeremonyAsync(
new ApproveCeremonyRequest
{
CeremonyId = ceremonyId,
ApprovalSignature = Convert.FromBase64String("c2lnMQ=="),
ApprovalReason = "First"
},
"approver@example.com");
approval1.Success.Should().BeTrue();
// Second approval from same approver should fail
var approval2 = await _orchestrator.ApproveCeremonyAsync(
new ApproveCeremonyRequest
{
CeremonyId = ceremonyId,
ApprovalSignature = Convert.FromBase64String("c2lnMg=="),
ApprovalReason = "Second"
},
"approver@example.com");
approval2.Success.Should().BeFalse();
approval2.ErrorCode.Should().Be(CeremonyErrorCode.DuplicateApproval);
}
#endregion
#region Cancellation Tests
[Fact]
public async Task CancelCeremony_WhenPending_Succeeds()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload { KeyId = "key-001" }
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Act
var cancelResult = await _orchestrator.CancelCeremonyAsync(
ceremonyId,
"admin@example.com",
"Cancelled for testing");
// Assert
cancelResult.Success.Should().BeTrue();
cancelResult.Ceremony!.State.Should().Be(CeremonyState.Cancelled);
}
[Fact]
public async Task CancelledCeremony_CannotBeApproved()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload { KeyId = "key-001" }
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Cancel
await _orchestrator.CancelCeremonyAsync(ceremonyId, "admin@example.com", "Cancelled");
// Attempt approval should fail
var approval = await _orchestrator.ApproveCeremonyAsync(
new ApproveCeremonyRequest
{
CeremonyId = ceremonyId,
ApprovalSignature = Convert.FromBase64String("c2ln"),
ApprovalReason = "Too late"
},
"approver@example.com");
approval.Success.Should().BeFalse();
}
#endregion
#region Get and List Tests
[Fact]
public async Task GetCeremony_WhenExists_ReturnsCeremony()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyGeneration,
Payload = new CeremonyOperationPayload
{
Algorithm = "ed25519",
Reason = "New signing key"
}
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Act
var ceremony = await _orchestrator.GetCeremonyAsync(ceremonyId);
// Assert
ceremony.Should().NotBeNull();
ceremony!.CeremonyId.Should().Be(ceremonyId);
}
[Fact]
public async Task ListCeremonies_WithFilter_ReturnsFilteredResults()
{
// Arrange - create multiple ceremonies
for (int i = 0; i < 3; i++)
{
await _orchestrator.CreateCeremonyAsync(new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
Payload = new CeremonyOperationPayload { KeyId = $"key-{i}" }
}, "initiator@example.com");
}
// Act
var ceremonies = await _orchestrator.ListCeremoniesAsync(new CeremonyFilter
{
State = CeremonyState.Pending
});
// Assert
ceremonies.Should().HaveCount(3);
ceremonies.Should().OnlyContain(c => c.State == CeremonyState.Pending);
}
#endregion
#region Setup Helpers
private void SetupRepositoryMock()
{
_mockRepository
.CreateAsync(Arg.Any<Ceremony>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var c = callInfo.Arg<Ceremony>();
_ceremoniesStore[c.CeremonyId] = c;
return Task.FromResult(c);
});
_mockRepository
.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var id = callInfo.Arg<Guid>();
_ceremoniesStore.TryGetValue(id, out var ceremony);
return Task.FromResult(ceremony);
});
_mockRepository
.UpdateStateAsync(
Arg.Any<Guid>(),
Arg.Any<CeremonyState>(),
Arg.Any<int>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var id = callInfo.Arg<Guid>();
var state = callInfo.ArgAt<CeremonyState>(1);
var threshold = callInfo.ArgAt<int>(2);
if (_ceremoniesStore.TryGetValue(id, out var c))
{
var updated = c with { State = state, ThresholdReached = threshold };
_ceremoniesStore[id] = updated;
return Task.FromResult<Ceremony?>(updated);
}
return Task.FromResult<Ceremony?>(null);
});
_mockRepository
.ListAsync(Arg.Any<CeremonyFilter>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var filter = callInfo.Arg<CeremonyFilter>();
var query = _ceremoniesStore.Values.AsEnumerable();
if (filter?.State != null)
query = query.Where(c => c.State == filter.State.Value);
if (filter?.OperationType != null)
query = query.Where(c => c.OperationType == filter.OperationType);
return Task.FromResult(query.ToList() as IReadOnlyList<Ceremony>);
});
_mockRepository
.AddApprovalAsync(Arg.Any<CeremonyApproval>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var approval = callInfo.Arg<CeremonyApproval>();
// Update the ceremony in the store with the new approval
if (_ceremoniesStore.TryGetValue(approval.CeremonyId, out var ceremony))
{
var newApprovals = ceremony.Approvals.ToList();
newApprovals.Add(approval);
_ceremoniesStore[approval.CeremonyId] = ceremony with { Approvals = newApprovals };
}
return Task.FromResult(approval);
});
_mockRepository
.HasApprovedAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var ceremonyId = callInfo.Arg<Guid>();
var approverIdentity = callInfo.ArgAt<string>(1);
// Check if already approved (simple check)
if (_ceremoniesStore.TryGetValue(ceremonyId, out var ceremony))
{
var alreadyApproved = ceremony.Approvals.Any(a => a.ApproverIdentity == approverIdentity);
return Task.FromResult(alreadyApproved);
}
return Task.FromResult(false);
});
}
private void SetupAuditSinkMock()
{
_mockAuditSink
.WriteAsync(Arg.Any<CeremonyAuditEvent>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var evt = callInfo.Arg<CeremonyAuditEvent>();
_auditEvents.Add(evt);
return Task.CompletedTask;
});
}
private void SetupApproverValidatorMock()
{
// Default: all approvers valid
_mockApproverValidator
.ValidateApproverAsync(
Arg.Any<string>(),
Arg.Any<CeremonyOperationType>(),
Arg.Any<byte[]>(),
Arg.Any<CancellationToken>())
.Returns(new ApproverValidationResult { IsValid = true });
}
#endregion
}
/// <summary>
/// Mock time provider for testing time-dependent behavior.
/// </summary>
internal sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetNow(DateTimeOffset now) => _now = now;
}

View File

@@ -0,0 +1,162 @@
// -----------------------------------------------------------------------------
// CeremonyStateMachineTests.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-011
// Description: Unit tests for ceremony state machine.
// -----------------------------------------------------------------------------
using StellaOps.Signer.Core.Ceremonies;
using Xunit;
namespace StellaOps.Signer.Tests.Ceremonies;
[Trait("Category", "Unit")]
public sealed class CeremonyStateMachineTests
{
[Theory]
[InlineData(CeremonyState.Pending, CeremonyState.PartiallyApproved, true)]
[InlineData(CeremonyState.Pending, CeremonyState.Approved, true)]
[InlineData(CeremonyState.Pending, CeremonyState.Expired, true)]
[InlineData(CeremonyState.Pending, CeremonyState.Cancelled, true)]
[InlineData(CeremonyState.Pending, CeremonyState.Executed, false)]
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.PartiallyApproved, true)]
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Approved, true)]
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Expired, true)]
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Cancelled, true)]
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Pending, false)]
[InlineData(CeremonyState.Approved, CeremonyState.Executed, true)]
[InlineData(CeremonyState.Approved, CeremonyState.Expired, true)]
[InlineData(CeremonyState.Approved, CeremonyState.Cancelled, true)]
[InlineData(CeremonyState.Approved, CeremonyState.Pending, false)]
[InlineData(CeremonyState.Approved, CeremonyState.PartiallyApproved, false)]
[InlineData(CeremonyState.Executed, CeremonyState.Pending, false)]
[InlineData(CeremonyState.Executed, CeremonyState.Cancelled, false)]
[InlineData(CeremonyState.Expired, CeremonyState.Pending, false)]
[InlineData(CeremonyState.Expired, CeremonyState.Approved, false)]
[InlineData(CeremonyState.Cancelled, CeremonyState.Pending, false)]
[InlineData(CeremonyState.Cancelled, CeremonyState.Approved, false)]
public void IsValidTransition_ReturnsExpectedResult(
CeremonyState currentState,
CeremonyState targetState,
bool expected)
{
var result = CeremonyStateMachine.IsValidTransition(currentState, targetState);
Assert.Equal(expected, result);
}
[Fact]
public void IsValidTransition_SameState_ReturnsFalse()
{
foreach (var state in Enum.GetValues<CeremonyState>())
{
// PartiallyApproved -> PartiallyApproved is intentionally allowed (more approvals)
if (state == CeremonyState.PartiallyApproved)
{
Assert.True(CeremonyStateMachine.IsValidTransition(state, state));
}
else
{
Assert.False(CeremonyStateMachine.IsValidTransition(state, state));
}
}
}
[Theory]
[InlineData(CeremonyState.Pending, 2, 1, CeremonyState.PartiallyApproved)]
[InlineData(CeremonyState.Pending, 2, 2, CeremonyState.Approved)]
[InlineData(CeremonyState.Pending, 1, 1, CeremonyState.Approved)]
[InlineData(CeremonyState.PartiallyApproved, 3, 2, CeremonyState.PartiallyApproved)]
[InlineData(CeremonyState.PartiallyApproved, 3, 3, CeremonyState.Approved)]
[InlineData(CeremonyState.PartiallyApproved, 2, 3, CeremonyState.Approved)] // Over threshold
public void ComputeStateAfterApproval_ReturnsExpectedState(
CeremonyState currentState,
int thresholdRequired,
int thresholdReached,
CeremonyState expectedState)
{
var result = CeremonyStateMachine.ComputeStateAfterApproval(
currentState,
thresholdRequired,
thresholdReached);
Assert.Equal(expectedState, result);
}
[Theory]
[InlineData(CeremonyState.Executed)]
[InlineData(CeremonyState.Expired)]
[InlineData(CeremonyState.Cancelled)]
public void ComputeStateAfterApproval_TerminalState_ThrowsException(CeremonyState state)
{
Assert.Throws<InvalidOperationException>(() =>
CeremonyStateMachine.ComputeStateAfterApproval(state, 2, 1));
}
[Theory]
[InlineData(CeremonyState.Pending, true)]
[InlineData(CeremonyState.PartiallyApproved, true)]
[InlineData(CeremonyState.Approved, false)]
[InlineData(CeremonyState.Executed, false)]
[InlineData(CeremonyState.Expired, false)]
[InlineData(CeremonyState.Cancelled, false)]
public void CanAcceptApproval_ReturnsExpectedResult(CeremonyState state, bool expected)
{
Assert.Equal(expected, CeremonyStateMachine.CanAcceptApproval(state));
}
[Theory]
[InlineData(CeremonyState.Pending, false)]
[InlineData(CeremonyState.PartiallyApproved, false)]
[InlineData(CeremonyState.Approved, true)]
[InlineData(CeremonyState.Executed, false)]
[InlineData(CeremonyState.Expired, false)]
[InlineData(CeremonyState.Cancelled, false)]
public void CanExecute_ReturnsExpectedResult(CeremonyState state, bool expected)
{
Assert.Equal(expected, CeremonyStateMachine.CanExecute(state));
}
[Theory]
[InlineData(CeremonyState.Pending, true)]
[InlineData(CeremonyState.PartiallyApproved, true)]
[InlineData(CeremonyState.Approved, true)]
[InlineData(CeremonyState.Executed, false)]
[InlineData(CeremonyState.Expired, false)]
[InlineData(CeremonyState.Cancelled, false)]
public void CanCancel_ReturnsExpectedResult(CeremonyState state, bool expected)
{
Assert.Equal(expected, CeremonyStateMachine.CanCancel(state));
}
[Theory]
[InlineData(CeremonyState.Pending, false)]
[InlineData(CeremonyState.PartiallyApproved, false)]
[InlineData(CeremonyState.Approved, false)]
[InlineData(CeremonyState.Executed, true)]
[InlineData(CeremonyState.Expired, true)]
[InlineData(CeremonyState.Cancelled, true)]
public void IsTerminalState_ReturnsExpectedResult(CeremonyState state, bool expected)
{
Assert.Equal(expected, CeremonyStateMachine.IsTerminalState(state));
}
[Fact]
public void GetStateDescription_ReturnsNonEmptyString()
{
foreach (var state in Enum.GetValues<CeremonyState>())
{
var description = CeremonyStateMachine.GetStateDescription(state);
Assert.False(string.IsNullOrWhiteSpace(description));
}
}
[Theory]
[InlineData(CeremonyState.Pending, "Awaiting approvals")]
[InlineData(CeremonyState.Approved, "All approvals received, ready for execution")]
[InlineData(CeremonyState.Executed, "Operation executed successfully")]
[InlineData(CeremonyState.Expired, "Ceremony expired before completion")]
public void GetStateDescription_ReturnsExpectedDescription(CeremonyState state, string expected)
{
Assert.Equal(expected, CeremonyStateMachine.GetStateDescription(state));
}
}

View File

@@ -0,0 +1,183 @@
// <copyright file="PredicateTypesTests.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-002)
// </copyright>
using FluentAssertions;
using StellaOps.Signer.Core;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Signer.Tests.Contract;
/// <summary>
/// Tests for PredicateTypes classification and allowlist behavior.
/// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-002)
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class PredicateTypesTests
{
[Theory]
[InlineData(PredicateTypes.PathWitnessCanonical)]
[InlineData(PredicateTypes.PathWitnessAlias1)]
[InlineData(PredicateTypes.PathWitnessAlias2)]
// Note: StellaOpsPathWitness equals PathWitnessAlias1, so not included to avoid duplicate
public void IsPathWitnessType_ReturnsTrueForAllPathWitnessTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsPathWitnessType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be recognized as a path witness type");
}
[Theory]
[InlineData(PredicateTypes.StellaOpsSbom)]
[InlineData(PredicateTypes.StellaOpsVex)]
[InlineData(PredicateTypes.StellaOpsPolicy)]
[InlineData(PredicateTypes.SlsaProvenanceV1)]
[InlineData("some-unknown-type")]
public void IsPathWitnessType_ReturnsFalseForNonPathWitnessTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsPathWitnessType(predicateType);
// Assert
result.Should().BeFalse($"{predicateType} should not be recognized as a path witness type");
}
[Theory]
[InlineData(PredicateTypes.PathWitnessCanonical)]
[InlineData(PredicateTypes.PathWitnessAlias1)]
[InlineData(PredicateTypes.PathWitnessAlias2)]
// Note: StellaOpsPathWitness equals PathWitnessAlias1, so not included to avoid duplicate
public void IsReachabilityRelatedType_ReturnsTrueForPathWitnessTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsReachabilityRelatedType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be classified as reachability-related");
}
[Theory]
[InlineData(PredicateTypes.StellaOpsGraph)]
[InlineData(PredicateTypes.StellaOpsReachabilityWitness)]
[InlineData(PredicateTypes.StellaOpsReachabilityDrift)]
public void IsReachabilityRelatedType_ReturnsTrueForOtherReachabilityTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsReachabilityRelatedType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be classified as reachability-related");
}
[Fact]
public void GetAllowedPredicateTypes_ContainsAllPathWitnessTypes()
{
// Act
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes().ToList();
// Assert
allowedTypes.Should().Contain(PredicateTypes.PathWitnessCanonical);
allowedTypes.Should().Contain(PredicateTypes.PathWitnessAlias1);
allowedTypes.Should().Contain(PredicateTypes.PathWitnessAlias2);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPathWitness);
}
[Theory]
[InlineData(PredicateTypes.PathWitnessCanonical)]
[InlineData(PredicateTypes.PathWitnessAlias1)]
[InlineData(PredicateTypes.PathWitnessAlias2)]
// Note: StellaOpsPathWitness equals PathWitnessAlias1, so not included to avoid duplicate
public void IsAllowedPredicateType_ReturnsTrueForPathWitnessTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsAllowedPredicateType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be in the allowed predicate list");
}
[Theory]
[InlineData("https://stella.ops/predicates/path-witness/v1")]
[InlineData("https://stella.ops/pathWitness/v1")]
[InlineData("https://stella.ops/other/predicate")]
[InlineData("https://stella-ops.org/predicates/test")]
public void IsStellaOpsType_RecognizesStellaOpsUriPrefixes(string predicateType)
{
// Act
var result = PredicateTypes.IsStellaOpsType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be recognized as a StellaOps type");
}
[Theory]
[InlineData("stella.ops/pathWitness@v1")]
[InlineData("stella.ops/sbom@v1")]
[InlineData("stella.ops/vex@v1")]
public void IsStellaOpsType_RecognizesStellaOpsDotSyntax(string predicateType)
{
// Act
var result = PredicateTypes.IsStellaOpsType(predicateType);
// Assert
result.Should().BeTrue($"{predicateType} should be recognized as a StellaOps type");
}
[Theory]
[InlineData("https://slsa.dev/provenance/v1")]
[InlineData("https://in-toto.io/Statement/v1")]
[InlineData("https://example.com/custom-predicate")]
public void IsStellaOpsType_ReturnsFalseForNonStellaOpsTypes(string predicateType)
{
// Act
var result = PredicateTypes.IsStellaOpsType(predicateType);
// Assert
result.Should().BeFalse($"{predicateType} should not be recognized as a StellaOps type");
}
[Fact]
public void PathWitnessConstants_HaveCorrectValues()
{
// Assert
PredicateTypes.PathWitnessCanonical.Should().Be("https://stella.ops/predicates/path-witness/v1");
PredicateTypes.PathWitnessAlias1.Should().Be("stella.ops/pathWitness@v1");
PredicateTypes.PathWitnessAlias2.Should().Be("https://stella.ops/pathWitness/v1");
PredicateTypes.StellaOpsPathWitness.Should().Be("stella.ops/pathWitness@v1");
}
[Fact]
public void PathWitnessAlias1_EqualsLegacyStellaOpsPathWitness()
{
// The alias should equal the legacy constant for backward compatibility
PredicateTypes.PathWitnessAlias1.Should().Be(PredicateTypes.StellaOpsPathWitness);
}
[Fact]
public void AllowedTypes_NoDuplicates()
{
// Act
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes().ToList();
var distinctTypes = allowedTypes.Distinct().ToList();
// Assert - Note: PathWitnessAlias1 equals StellaOpsPathWitness by design for compatibility
// The list has 30 entries, but 29 unique values (one intentional alias duplication)
// Includes: SLSA (2) + StellaOps core (14) + PathWitness canonical + aliases (3) + Delta (4) + Function Map (2) + Runtime Evidence (2) + Third-party (3)
distinctTypes.Count.Should().Be(29, "allowed types should have expected distinct count");
}
[Fact]
public void AllowedTypes_IsDeterministicallyOrdered()
{
// Act
var types1 = PredicateTypes.GetAllowedPredicateTypes().ToList();
var types2 = PredicateTypes.GetAllowedPredicateTypes().ToList();
// Assert - Same order on multiple calls
types1.Should().BeEquivalentTo(types2, options => options.WithStrictOrdering());
}
}

View File

@@ -0,0 +1,417 @@
// -----------------------------------------------------------------------------
// SignerContractSnapshotTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-011 - Add contract tests for Signer.WebService endpoints (sign request, verify request, key management) — OpenAPI snapshot
// Description: OpenAPI contract snapshot tests for Signer WebService
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Signer.Tests.Contract;
/// <summary>
/// Contract tests for Signer.WebService endpoints.
/// Validates:
/// - OpenAPI specification endpoints
/// - Sign/verify request structure
/// - Security requirements
/// - Response format stability
/// </summary>
[Trait("Category", "Contract")]
[Trait("Category", "WebService")]
[Trait("Category", "W1")]
public sealed class SignerContractSnapshotTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ITestOutputHelper _output;
public SignerContractSnapshotTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
#region OpenAPI Endpoint Tests
[Fact]
public async Task OpenApi_Endpoint_ReturnsValidJson()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/swagger/v1/swagger.json");
// Assert
if (response.StatusCode == HttpStatusCode.NotFound)
{
// OpenAPI endpoint may be disabled in production
_output.WriteLine("⚠ OpenAPI endpoint not available (may be disabled in production config)");
return;
}
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
doc.RootElement.GetProperty("openapi").GetString().Should().StartWith("3.");
_output.WriteLine("✓ OpenAPI endpoint returns valid JSON");
}
[Fact]
public async Task OpenApi_ContainsSignDsseEndpoint()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/swagger/v1/swagger.json");
if (response.StatusCode == HttpStatusCode.NotFound)
{
_output.WriteLine("⚠ OpenAPI endpoint not available");
return;
}
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Assert
var paths = doc.RootElement.GetProperty("paths");
var signDssePath = paths.EnumerateObject()
.FirstOrDefault(p => p.Name.Contains("sign/dsse") || p.Name.Contains("signer"));
signDssePath.Name.Should().NotBeNullOrEmpty();
_output.WriteLine($"✓ Sign DSSE endpoint found: {signDssePath.Name}");
}
#endregion
#region Sign Endpoint Contract Tests
[Fact]
public async Task SignDsse_RequiresAuthentication()
{
// Arrange
var client = _factory.CreateClient();
var request = CreateBasicSignRequest();
// Act - no auth header
var response = await client.PostAsJsonAsync("/api/v1/signer/sign/dsse", request);
// Assert - should require auth
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Unauthorized,
HttpStatusCode.Forbidden);
_output.WriteLine("✓ Sign DSSE endpoint requires authentication");
}
[Fact]
public async Task SignDsse_ValidRequest_ReturnsExpectedStructure()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - either success or proper error structure
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
doc.RootElement.TryGetProperty("bundle", out _).Should().BeTrue("response should include bundle");
_output.WriteLine("✓ Sign DSSE returns expected structure with bundle");
}
else
{
// Forbidden/BadRequest are acceptable for stub tokens
response.StatusCode.Should().BeOneOf(
HttpStatusCode.Forbidden,
HttpStatusCode.BadRequest,
HttpStatusCode.UnprocessableEntity);
_output.WriteLine($"✓ Sign DSSE returns proper error status: {response.StatusCode}");
}
}
[Fact]
public async Task SignDsse_MissingFields_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var incompleteRequest = new { subject = new object[] { } }; // Missing required fields
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(incompleteRequest)
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.UnprocessableEntity);
_output.WriteLine("✓ Sign DSSE returns 400 for missing fields");
}
#endregion
#region Verify Endpoint Contract Tests
[Fact]
public async Task VerifyDsse_Endpoint_Exists()
{
// Arrange
var client = _factory.CreateClient();
// Act - try to verify (even if it fails, endpoint should exist)
var response = await client.PostAsJsonAsync("/api/v1/signer/verify/dsse", new { });
// Assert - should not be 404 (endpoint exists)
response.StatusCode.Should().NotBe(HttpStatusCode.NotFound,
"verify/dsse endpoint should exist");
_output.WriteLine($"✓ Verify DSSE endpoint exists, returns: {response.StatusCode}");
}
#endregion
#region Health Endpoint Tests
[Fact]
public async Task Health_Endpoint_ReturnsOk()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
if (response.StatusCode == HttpStatusCode.NotFound)
{
// Try alternative paths
response = await client.GetAsync("/healthz");
if (response.StatusCode == HttpStatusCode.NotFound)
{
response = await client.GetAsync("/api/health");
}
}
// Health endpoint should be 200 or 503 (degraded) but not 404
if (response.StatusCode != HttpStatusCode.NotFound)
{
response.StatusCode.Should().BeOneOf(
HttpStatusCode.OK,
HttpStatusCode.ServiceUnavailable);
_output.WriteLine($"✓ Health endpoint returns: {response.StatusCode}");
}
else
{
_output.WriteLine("⚠ Health endpoint not found (may be configured differently)");
}
}
#endregion
#region Content-Type Contract Tests
[Fact]
public async Task SignDsse_RequiresJsonContentType()
{
// Arrange
var client = _factory.CreateClient();
var content = new StringContent("not-json", Encoding.UTF8, "text/plain");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.UnsupportedMediaType,
HttpStatusCode.Unauthorized);
_output.WriteLine("✓ Sign DSSE requires JSON content type");
}
[Fact]
public async Task SignDsse_Response_HasJsonContentType()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
if (response.Content.Headers.ContentType != null)
{
response.Content.Headers.ContentType.MediaType
.Should().BeOneOf("application/json", "application/problem+json");
_output.WriteLine("✓ Response has JSON content type");
}
}
#endregion
#region Security Header Tests
[Fact]
public async Task SignDsse_RequiresDPoPHeader()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Note: NOT adding DPoP header
// Act
var response = await client.SendAsync(request);
// Assert - signing operations may require DPoP proof
// This validates the security contract
if (response.StatusCode == HttpStatusCode.Forbidden ||
response.StatusCode == HttpStatusCode.Unauthorized)
{
_output.WriteLine("✓ Sign DSSE properly enforces DPoP requirement");
}
else
{
_output.WriteLine($" Sign DSSE returned {response.StatusCode} without DPoP (may be optional)");
}
}
#endregion
#region Error Response Format Tests
[Fact]
public async Task ErrorResponse_HasDeterministicStructure()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("{invalid-json", Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(content))
{
var doc = JsonDocument.Parse(content);
// Check for standard error properties
var hasErrorInfo = doc.RootElement.TryGetProperty("type", out _) ||
doc.RootElement.TryGetProperty("title", out _) ||
doc.RootElement.TryGetProperty("error", out _) ||
doc.RootElement.TryGetProperty("message", out _);
hasErrorInfo.Should().BeTrue("error response should have structured error info");
_output.WriteLine("✓ Error response has deterministic structure");
}
}
#endregion
#region Contract Hash Test
[Fact]
public async Task OpenApi_Contract_HashIsStable()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/swagger/v1/swagger.json");
if (response.StatusCode == HttpStatusCode.NotFound)
{
_output.WriteLine("⚠ OpenAPI endpoint not available for hash check");
return;
}
var content = await response.Content.ReadAsStringAsync();
// Normalize JSON for stable hashing
var doc = JsonDocument.Parse(content);
var normalized = JsonSerializer.Serialize(doc.RootElement);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
_output.WriteLine($"✓ OpenAPI contract hash: {hash[..16]}...");
_output.WriteLine(" (Hash changes indicate contract modification - review for breaking changes)");
}
#endregion
#region Helper Methods
private static object CreateBasicSignRequest()
{
return new
{
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass", timestamp = DateTimeOffset.UtcNow.ToString("o") },
scannerImageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" }
};
}
#endregion
}

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(2030, 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,64 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.SmSoft;
namespace StellaOps.Signer.Tests.Fixtures;
public static partial class TestCryptoFactory
{
public static ICryptoProviderRegistry CreateSm2Registry()
{
var services = new ServiceCollection();
services.Configure<SmSoftProviderOptions>(opts =>
{
opts.RequireEnvironmentGate = true;
});
services.AddSingleton<ICryptoProvider, SmSoftCryptoProvider>();
services.AddSingleton<ICryptoProviderRegistry>(sp =>
{
var providers = sp.GetServices<ICryptoProvider>();
return new CryptoProviderRegistry(providers, new[] { "cn.sm.soft" });
});
var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
// Seed a test key
var previousGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
try
{
var smProvider = (SmSoftCryptoProvider)provider.GetRequiredService<ICryptoProvider>();
var key = Sm2TestKeyFactory.Create("sm2-key");
smProvider.UpsertSigningKey(key);
}
finally
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", previousGate);
}
return registry;
}
}
internal static class Sm2TestKeyFactory
{
public static CryptoSigningKey Create(string keyId)
{
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
var generator = new ECKeyPairGenerator("EC");
generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
var pair = generator.GenerateKeyPair();
var privateDer = PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private).GetDerEncoded();
var reference = new CryptoKeyReference(keyId, "cn.sm.soft");
return new CryptoSigningKey(reference, SignatureAlgorithms.Sm2, privateDer, DateTimeOffset.UtcNow);
}
}

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 partial 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,419 @@
// -----------------------------------------------------------------------------
// KeyRotationWorkflowIntegrationTests.cs
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
// Task: PROOF-KEY-0013 - Integration tests for rotation workflow
// Description: End-to-end integration tests for the full key rotation workflow
// -----------------------------------------------------------------------------
using System;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.EfCore.Context;
using StellaOps.Signer.KeyManagement.Entities;
using StellaOps.Signer.WebService.Endpoints;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for the complete key rotation workflow.
/// Tests the full lifecycle: add key -> transition period -> revoke old key.
/// </summary>
public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly InMemoryDatabaseRoot _databaseRoot = new();
private readonly string _databaseName = $"IntegrationTestDb_{Guid.NewGuid()}";
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private readonly ITestOutputHelper _output;
private Guid _testAnchorId;
public KeyRotationWorkflowIntegrationTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_output = output;
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Use in-memory database for tests
services.RemoveAll<KeyManagementDbContext>();
services.RemoveAll<DbContextOptions<KeyManagementDbContext>>();
services.RemoveAll<IDbContextFactory<KeyManagementDbContext>>();
services.AddDbContext<KeyManagementDbContext>(options =>
options.UseInMemoryDatabase(_databaseName, _databaseRoot));
});
});
_client = _factory.CreateClient();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
}
private async Task DumpResponseAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}");
_output.WriteLine($"Body: {body}");
}
public async ValueTask InitializeAsync()
{
// Create a test trust anchor
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<KeyManagementDbContext>();
await dbContext.Database.EnsureCreatedAsync();
_testAnchorId = Guid.NewGuid();
var anchor = new TrustAnchorEntity
{
AnchorId = _testAnchorId,
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["initial-key"],
RevokedKeyIds = [],
PolicyVersion = "v1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
dbContext.TrustAnchors.Add(anchor);
dbContext.KeyHistory.Add(new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = _testAnchorId,
KeyId = "initial-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ninitial-test-key\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = DateTimeOffset.UtcNow.AddMonths(-6),
CreatedAt = DateTimeOffset.UtcNow.AddMonths(-6)
});
await dbContext.SaveChangesAsync();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
#region Full Rotation Workflow Tests
[Fact]
public async Task FullRotationWorkflow_AddNewKey_TransitionPeriod_RevokeOldKey()
{
// Step 1: Add new key (begin transition period)
var addKeyRequest = new AddKeyRequestDto
{
KeyId = "new-key-2025",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
var addResponse = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
addKeyRequest);
if (addResponse.StatusCode != HttpStatusCode.Created)
{
await DumpResponseAsync(addResponse);
}
addResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var addResult = await addResponse.Content.ReadFromJsonAsync<AddKeyResponseDto>();
addResult!.AllowedKeyIds.Should().Contain("initial-key");
addResult.AllowedKeyIds.Should().Contain("new-key-2025");
// Step 2: Verify both keys are valid during transition period
var signedAt = DateTimeOffset.UtcNow;
var signedAtQuery = EncodeSignedAt(signedAt);
var validity1 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={signedAtQuery}");
var validity2 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/new-key-2025/validity?signedAt={signedAtQuery}");
validity1!.IsValid.Should().BeTrue();
validity2!.IsValid.Should().BeTrue();
// Step 3: Revoke old key
var revokeRequest = new RevokeKeyRequestDto
{
Reason = "rotation-complete"
};
var revokeResponse = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/revoke",
revokeRequest);
if (revokeResponse.StatusCode != HttpStatusCode.OK)
{
await DumpResponseAsync(revokeResponse);
}
revokeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var revokeResult = await revokeResponse.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
revokeResult!.AllowedKeyIds.Should().NotContain("initial-key");
revokeResult.AllowedKeyIds.Should().Contain("new-key-2025");
revokeResult.RevokedKeyIds.Should().Contain("initial-key");
// Step 4: Verify key history is complete
var history = await _client.GetFromJsonAsync<KeyHistoryResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/history");
history!.Entries.Should().HaveCount(2);
var oldKeyEntry = history.Entries.First(e => e.KeyId == "initial-key");
oldKeyEntry.RevokedAt.Should().NotBeNull();
oldKeyEntry.RevokeReason.Should().Be("rotation-complete");
var newKeyEntry = history.Entries.First(e => e.KeyId == "new-key-2025");
newKeyEntry.RevokedAt.Should().BeNull();
}
[Fact]
public async Task HistoricalProofVerification_SignedBeforeRevocation_RemainsValid()
{
// Arrange: add and revoke a key
var addRequest = new AddKeyRequestDto
{
KeyId = "old-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
// Record time before revocation
var signedBeforeRevocation = DateTimeOffset.UtcNow;
var signedBeforeQuery = EncodeSignedAt(signedBeforeRevocation);
// Revoke the key
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/revoke",
revokeRequest);
// Act: check validity at time before revocation
var validityResponse = await _client.GetAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/validity?signedAt={signedBeforeQuery}");
if (!validityResponse.IsSuccessStatusCode)
{
await DumpResponseAsync(validityResponse);
}
var validity = await validityResponse.Content.ReadFromJsonAsync<KeyValidityResponseDto>();
// Assert: key should be valid for proofs signed before revocation
validity!.IsValid.Should().BeTrue("proofs signed before revocation should remain valid");
}
[Fact]
public async Task HistoricalProofVerification_SignedAfterRevocation_IsInvalid()
{
// Arrange: add a key, then revoke it
var addRequest = new AddKeyRequestDto
{
KeyId = "revoked-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/revoke",
revokeRequest);
// Act: check validity at time after revocation
var signedAfterRevocation = DateTimeOffset.UtcNow.AddMinutes(5);
var signedAfterQuery = EncodeSignedAt(signedAfterRevocation);
var validityResponse = await _client.GetAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/validity?signedAt={signedAfterQuery}");
if (!validityResponse.IsSuccessStatusCode)
{
await DumpResponseAsync(validityResponse);
}
var validity = await validityResponse.Content.ReadFromJsonAsync<KeyValidityResponseDto>();
// Assert: key should be invalid for proofs signed after revocation
validity!.IsValid.Should().BeFalse("proofs signed after revocation should be invalid");
validity.Status.Should().Be("Revoked");
}
#endregion
#region Audit Trail Tests
[Fact]
public async Task AddKey_CreatesAuditLogEntry()
{
// Arrange
var request = new AddKeyRequestDto
{
KeyId = "audited-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
if (!response.IsSuccessStatusCode)
{
await DumpResponseAsync(response);
}
// Assert
var result = await response.Content.ReadFromJsonAsync<AddKeyResponseDto>();
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
}
[Fact]
public async Task RevokeKey_CreatesAuditLogEntry()
{
// Arrange: first add a key
var addRequest = new AddKeyRequestDto
{
KeyId = "key-to-revoke",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
// Act
var revokeRequest = new RevokeKeyRequestDto { Reason = "audit-test" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/key-to-revoke/revoke",
revokeRequest);
if (!response.IsSuccessStatusCode)
{
await DumpResponseAsync(response);
}
// Assert
var result = await response.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
}
#endregion
#region Rotation Warnings Tests
[Fact]
public async Task GetRotationWarnings_ReturnsRelevantWarnings()
{
// Act
var response = await _client.GetAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/warnings");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var warnings = await response.Content.ReadFromJsonAsync<RotationWarningsResponseDto>();
warnings.Should().NotBeNull();
warnings!.AnchorId.Should().Be(_testAnchorId);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task AddKey_DuplicateKeyId_Returns400()
{
// Arrange: add a key
var request = new AddKeyRequestDto
{
KeyId = "duplicate-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", request);
// Act: try to add same key again
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
if (response.StatusCode != HttpStatusCode.BadRequest)
{
await DumpResponseAsync(response);
}
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task RevokeKey_NonexistentKey_Returns404()
{
// Arrange
var request = new RevokeKeyRequestDto { Reason = "test" };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/nonexistent-key/revoke",
request);
if (response.StatusCode != HttpStatusCode.NotFound)
{
await DumpResponseAsync(response);
}
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task AddKey_InvalidAlgorithm_Returns400()
{
// Arrange
var request = new AddKeyRequestDto
{
KeyId = "bad-algo-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "UNKNOWN-ALG"
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
if (response.StatusCode != HttpStatusCode.BadRequest)
{
await DumpResponseAsync(response);
}
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
private static string EncodeSignedAt(DateTimeOffset value)
=> Uri.EscapeDataString(value.ToString("O", CultureInfo.InvariantCulture));
#endregion
}
/// <summary>
/// Test key material.
/// </summary>
internal static class TestKeys
{
// Test Ed25519 public key (not for production use)
public const string Ed25519PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAGb9F2CMC7IaKG1svU1lN3Rjzk6uqO1l8dSEIAKDU8g0=
-----END PUBLIC KEY-----
""";
}

View File

@@ -0,0 +1,569 @@
// -----------------------------------------------------------------------------
// MultiPluginSignVerifyIntegrationTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-015 - Add integration test: canonical payload → sign (multiple plugins) → verify (all succeed)
// Description: Integration tests for signing with multiple crypto plugins and verifying all succeed
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for multi-plugin sign/verify workflow.
/// Validates:
/// - Canonical payload can be signed by all available plugins
/// - Each signature can be verified by the corresponding plugin
/// - Signatures from different plugins are independent
/// - All plugins produce valid, verifiable signatures for the same payload
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "SignVerify")]
[Trait("Category", "MultiPlugin")]
public sealed class MultiPluginSignVerifyIntegrationTests
{
private readonly ITestOutputHelper _output;
public MultiPluginSignVerifyIntegrationTests(ITestOutputHelper output)
{
_output = output;
}
#region Canonical Payload Tests
[Fact]
public void CanonicalPayload_ProducesDeterministicBytes()
{
// Arrange
var statement = CreateInTotoStatement();
// Act - serialize twice
var bytes1 = CanonicalizeStatement(statement);
var bytes2 = CanonicalizeStatement(statement);
// Assert
bytes1.Should().BeEquivalentTo(bytes2,
"canonical serialization should be deterministic");
_output.WriteLine($"Canonical payload size: {bytes1.Length} bytes");
_output.WriteLine($"SHA256: {ComputeSha256(bytes1)}");
}
[Fact]
public void CanonicalPayload_HasStableHash()
{
// Arrange
var statement = CreateInTotoStatement();
// Act
var hash1 = ComputeSha256(CanonicalizeStatement(statement));
var hash2 = ComputeSha256(CanonicalizeStatement(statement));
// Assert
hash1.Should().Be(hash2, "hash of canonical payload should be stable");
_output.WriteLine($"Stable hash: {hash1}");
}
#endregion
#region Multi-Plugin Sign/Verify Tests
[Fact]
public void AllPlugins_CanSignCanonicalPayload()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
_output.WriteLine($"Testing {plugins.Count} plugins:");
// Act & Assert
foreach (var plugin in plugins)
{
_output.WriteLine($" - {plugin.Name}: {plugin.Algorithm}");
// Each plugin should be able to sign (even if just simulation)
var signature = plugin.Sign(payload);
signature.Should().NotBeNullOrEmpty($"{plugin.Name} should produce a signature");
_output.WriteLine($" Signature length: {signature.Length} bytes");
}
}
[Fact]
public void AllPlugins_SignAndVerifyRoundtrip()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
var results = new List<(string PluginName, bool Success, string Details)>();
// Act
foreach (var plugin in plugins)
{
try
{
var signature = plugin.Sign(payload);
var verified = plugin.Verify(payload, signature);
results.Add((plugin.Name, verified, $"Algorithm: {plugin.Algorithm}"));
}
catch (Exception ex)
{
results.Add((plugin.Name, false, $"Error: {ex.Message}"));
}
}
// Assert
_output.WriteLine("=== Sign/Verify Roundtrip Results ===");
foreach (var (name, success, details) in results)
{
var status = success ? "✓" : "✗";
_output.WriteLine($" {status} {name}: {details}");
}
results.Should().AllSatisfy(r => r.Success.Should().BeTrue($"{r.PluginName} should verify its own signature"));
}
[Fact]
public void AllPlugins_SignaturesAreIndependent()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
var signatures = new Dictionary<string, byte[]>();
// Act - collect signatures from all plugins
foreach (var plugin in plugins)
{
signatures[plugin.Name] = plugin.Sign(payload);
}
// Assert - signatures should be different (unless same algorithm)
_output.WriteLine("=== Signature Independence ===");
var signatureHashes = signatures.ToDictionary(
kvp => kvp.Key,
kvp => ComputeSha256(kvp.Value));
foreach (var (name, hash) in signatureHashes)
{
_output.WriteLine($" {name}: {hash.Substring(0, 16)}...");
}
// Most signatures should be unique (some algorithms may be deterministic)
var uniqueSignatures = signatureHashes.Values.Distinct().Count();
_output.WriteLine($"Unique signatures: {uniqueSignatures}/{signatures.Count}");
uniqueSignatures.Should().BeGreaterThanOrEqualTo(Math.Max(1, signatures.Count / 2),
"different plugins should generally produce different signatures");
}
[Fact]
public void CrossPluginVerification_FailsForMismatchedSignatures()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
if (plugins.Count < 2)
{
_output.WriteLine("Skipping cross-plugin test: need at least 2 plugins");
return;
}
// Act - sign with first plugin
var plugin1 = plugins[0];
var plugin2 = plugins[1];
var signature = plugin1.Sign(payload);
// Try to verify with second plugin (should fail unless same algorithm)
var crossVerified = plugin2.Verify(payload, signature);
// Assert
_output.WriteLine($"Signed with: {plugin1.Name} ({plugin1.Algorithm})");
_output.WriteLine($"Verified with: {plugin2.Name} ({plugin2.Algorithm})");
_output.WriteLine($"Cross-verification result: {crossVerified}");
if (plugin1.Algorithm != plugin2.Algorithm)
{
crossVerified.Should().BeFalse(
"signature from one plugin should not verify with a different plugin");
}
}
#endregion
#region Concurrent Plugin Tests
[Fact]
public async Task AllPlugins_ConcurrentSigning_AllSucceed()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
// Act - sign concurrently
var tasks = plugins.Select(async plugin =>
{
await Task.Yield();
var signature = plugin.Sign(payload);
return (Plugin: plugin.Name, Signature: signature);
});
var results = await Task.WhenAll(tasks);
// Assert
_output.WriteLine("=== Concurrent Signing Results ===");
foreach (var result in results)
{
_output.WriteLine($" {result.Plugin}: {result.Signature.Length} bytes");
result.Signature.Should().NotBeNullOrEmpty();
}
results.Should().HaveCount(plugins.Count);
}
[Fact]
public async Task AllPlugins_ConcurrentVerification_AllSucceed()
{
// Arrange
var payload = CanonicalizeStatement(CreateInTotoStatement());
var plugins = GetAvailablePlugins();
var signedPairs = plugins.Select(p => (Plugin: p, Signature: p.Sign(payload))).ToList();
// Act - verify concurrently
var tasks = signedPairs.Select(async pair =>
{
await Task.Yield();
var verified = pair.Plugin.Verify(payload, pair.Signature);
return (Plugin: pair.Plugin.Name, Verified: verified);
});
var results = await Task.WhenAll(tasks);
// Assert
_output.WriteLine("=== Concurrent Verification Results ===");
foreach (var result in results)
{
var status = result.Verified ? "✓" : "✗";
_output.WriteLine($" {status} {result.Plugin}");
}
results.Should().AllSatisfy(r => r.Verified.Should().BeTrue($"{r.Plugin} should verify"));
}
#endregion
#region Large Payload Tests
[Theory]
[InlineData(1024)] // 1 KB
[InlineData(1024 * 100)] // 100 KB
[InlineData(1024 * 1024)] // 1 MB
public void AllPlugins_SignLargePayload_AllSucceed(int payloadSize)
{
// Arrange
var payload = CreateLargePayload(payloadSize);
var plugins = GetAvailablePlugins();
_output.WriteLine($"Testing with {payloadSize / 1024} KB payload");
// Act & Assert
foreach (var plugin in plugins)
{
var signature = plugin.Sign(payload);
var verified = plugin.Verify(payload, signature);
_output.WriteLine($" {plugin.Name}: {(verified ? "" : "")} ({signature.Length} byte signature)");
verified.Should().BeTrue($"{plugin.Name} should sign/verify large payload");
}
}
#endregion
#region Multiple Subjects Tests
[Theory]
[InlineData(1)]
[InlineData(10)]
[InlineData(100)]
public void AllPlugins_SignMultipleSubjects_AllSucceed(int subjectCount)
{
// Arrange
var statement = CreateInTotoStatementWithMultipleSubjects(subjectCount);
var payload = CanonicalizeStatement(statement);
var plugins = GetAvailablePlugins();
_output.WriteLine($"Testing with {subjectCount} subjects");
_output.WriteLine($"Payload size: {payload.Length} bytes");
// Act & Assert
foreach (var plugin in plugins)
{
var signature = plugin.Sign(payload);
var verified = plugin.Verify(payload, signature);
verified.Should().BeTrue($"{plugin.Name} should handle {subjectCount} subjects");
}
_output.WriteLine($"All {plugins.Count} plugins succeeded with {subjectCount} subjects");
}
#endregion
#region Helper Classes and Methods
private static List<ITestCryptoPlugin> GetAvailablePlugins()
{
return new List<ITestCryptoPlugin>
{
new Ed25519SimPlugin(),
new Es256SimPlugin(),
new Rs256SimPlugin(),
new GostSimPlugin(),
new Sm2SimPlugin()
};
}
private static object CreateInTotoStatement()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new
{
result = "pass",
timestamp = "2024-01-01T00:00:00Z" // Fixed timestamp for determinism
}
};
}
private static object CreateInTotoStatementWithMultipleSubjects(int count)
{
var subjects = Enumerable.Range(0, count).Select(i => new
{
name = $"pkg:npm/example-{i}@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"subject-{i}"))).ToLower()
}
}).ToArray();
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = subjects,
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass", subjectCount = count }
};
}
private static byte[] CanonicalizeStatement(object statement)
{
// Use ordered JSON serialization for canonical form
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null, // Preserve original case
WriteIndented = false, // No indentation for canonical form
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never
};
var json = JsonSerializer.Serialize(statement, options);
return Encoding.UTF8.GetBytes(json);
}
private static byte[] CreateLargePayload(int size)
{
var statement = new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/large-payload@1.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
},
predicateType = "https://example.com/test/v1",
predicate = new
{
data = new string('x', size) // Fill with data to reach target size
}
};
return CanonicalizeStatement(statement);
}
private static string ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(data);
return Convert.ToHexString(hash).ToLower();
}
#endregion
#region Test Plugin Implementations
private interface ITestCryptoPlugin
{
string Name { get; }
string Algorithm { get; }
byte[] Sign(byte[] payload);
bool Verify(byte[] payload, byte[] signature);
}
private sealed class Ed25519SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
private readonly byte[] _publicKey;
public Ed25519SimPlugin()
{
// Generate deterministic test keys
var seed = SHA256.HashData(Encoding.UTF8.GetBytes("ed25519-test-key"));
_privateKey = seed;
_publicKey = SHA256.HashData(seed);
}
public string Name => "BouncyCastle-Ed25519";
public string Algorithm => "Ed25519";
public byte[] Sign(byte[] payload)
{
// Simulate Ed25519 signature (deterministic for testing)
using var hmac = new HMACSHA512(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
return signature.SequenceEqual(expected);
}
}
private sealed class Es256SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public Es256SimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("ecdsa-p256-test-key"));
}
public string Name => "eIDAS-ECDSA";
public string Algorithm => "ES256";
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA256(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
return signature.SequenceEqual(expected);
}
}
private sealed class Rs256SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public Rs256SimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("rsa-2048-test-key"));
}
public string Name => "eIDAS-RSA";
public string Algorithm => "RS256";
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA256(_privateKey);
var hash = hmac.ComputeHash(payload);
// RSA signatures are typically 256 bytes for 2048-bit keys
return Enumerable.Repeat(hash, 8).SelectMany(x => x).ToArray();
}
public bool Verify(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
return signature.SequenceEqual(expected);
}
}
private sealed class GostSimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public GostSimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("gost-r34102012-test-key"));
}
public string Name => "CryptoPro-GOST";
public string Algorithm => "GOST_R3410_2012_256";
public byte[] Sign(byte[] payload)
{
// GOST signature simulation
using var hmac = new HMACSHA256(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
return signature.SequenceEqual(expected);
}
}
private sealed class Sm2SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public Sm2SimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("sm2-test-key"));
}
public string Name => "SimRemote-SM2";
public string Algorithm => "SM2";
public byte[] Sign(byte[] payload)
{
// SM2 signature simulation
using var hmac = new HMACSHA256(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
return signature.SequenceEqual(expected);
}
}
#endregion
}

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,790 @@
// -----------------------------------------------------------------------------
// TamperedPayloadVerificationTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-016 - Add integration test: tampered payload → verify fails with deterministic error
// Description: Integration tests verifying tampered payloads fail verification with deterministic errors
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for tampered payload detection.
/// Validates:
/// - Any modification to signed payload causes verification failure
/// - Tampering detection is deterministic across runs
/// - Error codes/messages are consistent for tampered payloads
/// - Different types of tampering are all detected
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "TamperDetection")]
[Trait("Category", "Security")]
public sealed class TamperedPayloadVerificationTests
{
private readonly ITestOutputHelper _output;
// Expected error codes for tampering detection
private const string TamperErrorCode = "SIGNER_SIGNATURE_INVALID";
private const string TamperErrorMessage = "signature verification failed";
public TamperedPayloadVerificationTests(ITestOutputHelper output)
{
_output = output;
}
#region Basic Tampering Tests
[Fact]
public void TamperedPayload_SingleBitFlip_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
// Tamper: flip a single bit
var tamperedPayload = (byte[])originalPayload.Clone();
tamperedPayload[tamperedPayload.Length / 2] ^= 0x01;
// Act
var originalVerifies = plugin.Verify(originalPayload, signature);
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
originalVerifies.Should().BeTrue("original payload should verify");
tamperedVerifies.Should().BeFalse("tampered payload should NOT verify");
_output.WriteLine("✓ Single bit flip detected");
_output.WriteLine($" Original: verified={originalVerifies}");
_output.WriteLine($" Tampered: verified={tamperedVerifies}");
}
[Fact]
public void TamperedPayload_PrependedByte_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
// Tamper: prepend a byte
var tamperedPayload = new byte[originalPayload.Length + 1];
tamperedPayload[0] = 0xFF;
Array.Copy(originalPayload, 0, tamperedPayload, 1, originalPayload.Length);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("prepended payload should NOT verify");
_output.WriteLine("✓ Prepended byte detected");
}
[Fact]
public void TamperedPayload_AppendedByte_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
// Tamper: append a byte
var tamperedPayload = new byte[originalPayload.Length + 1];
Array.Copy(originalPayload, tamperedPayload, originalPayload.Length);
tamperedPayload[^1] = 0xFF;
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("appended payload should NOT verify");
_output.WriteLine("✓ Appended byte detected");
}
[Fact]
public void TamperedPayload_RemovedByte_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
// Tamper: remove last byte
var tamperedPayload = originalPayload[..^1];
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("truncated payload should NOT verify");
_output.WriteLine("✓ Removed byte detected");
}
[Fact]
public void TamperedPayload_SwappedBytes_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
// Tamper: swap two adjacent bytes
var tamperedPayload = (byte[])originalPayload.Clone();
var midpoint = tamperedPayload.Length / 2;
(tamperedPayload[midpoint], tamperedPayload[midpoint + 1]) =
(tamperedPayload[midpoint + 1], tamperedPayload[midpoint]);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("byte-swapped payload should NOT verify");
_output.WriteLine("✓ Swapped bytes detected");
}
#endregion
#region JSON Content Tampering Tests
[Fact]
public void TamperedPayload_ModifiedDigest_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatement();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: modify the digest
var tamperedStatement = CreateStatementWithModifiedDigest();
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedPayload.Should().NotBeEquivalentTo(originalPayload,
"tampered payload should be different");
tamperedVerifies.Should().BeFalse("modified digest should NOT verify");
_output.WriteLine("✓ Modified digest detected");
}
[Fact]
public void TamperedPayload_ModifiedSubjectName_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatement();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: modify subject name
var tamperedStatement = CreateStatementWithModifiedSubjectName();
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("modified subject name should NOT verify");
_output.WriteLine("✓ Modified subject name detected");
}
[Fact]
public void TamperedPayload_ModifiedPredicateType_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatement();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: modify predicate type
var tamperedStatement = CreateStatementWithModifiedPredicateType();
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("modified predicate type should NOT verify");
_output.WriteLine("✓ Modified predicate type detected");
}
[Fact]
public void TamperedPayload_ModifiedPredicateContent_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatement();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: modify predicate content
var tamperedStatement = CreateStatementWithModifiedPredicate();
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("modified predicate should NOT verify");
_output.WriteLine("✓ Modified predicate content detected");
}
[Fact]
public void TamperedPayload_AddedSubject_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatement();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: add extra subject
var tamperedStatement = CreateStatementWithAddedSubject();
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("added subject should NOT verify");
_output.WriteLine("✓ Added subject detected");
}
[Fact]
public void TamperedPayload_RemovedSubject_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var statement = CreateStatementWithMultipleSubjects();
var originalPayload = SerializeToCanonical(statement);
var signature = plugin.Sign(originalPayload);
// Tamper: remove a subject
var tamperedStatement = CreateStatement(); // Single subject version
var tamperedPayload = SerializeToCanonical(tamperedStatement);
// Act
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
// Assert
tamperedVerifies.Should().BeFalse("removed subject should NOT verify");
_output.WriteLine("✓ Removed subject detected");
}
#endregion
#region Deterministic Error Code Tests
[Fact]
public void TamperedPayload_ErrorCode_IsDeterministic()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var originalPayload = CreateCanonicalPayload();
var signature = plugin.Sign(originalPayload);
var tamperedPayload = (byte[])originalPayload.Clone();
tamperedPayload[0] ^= 0xFF;
// Act - verify multiple times
var results = new List<VerificationResult>();
for (int i = 0; i < 10; i++)
{
results.Add(plugin.VerifyWithResult(tamperedPayload, signature));
}
// Assert - all results should be identical
var firstResult = results[0];
results.Should().AllSatisfy(r =>
{
r.Success.Should().Be(firstResult.Success);
r.ErrorCode.Should().Be(firstResult.ErrorCode);
});
_output.WriteLine($"Deterministic error code: {firstResult.ErrorCode}");
_output.WriteLine($"Verified across {results.Count} runs");
}
[Fact]
public void TamperedPayload_ErrorMessage_IsConsistent()
{
// Arrange
var plugins = GetAllPlugins();
var originalPayload = CreateCanonicalPayload();
_output.WriteLine("=== Error Messages for Tampered Payloads ===");
foreach (var plugin in plugins)
{
var signature = plugin.Sign(originalPayload);
var tamperedPayload = (byte[])originalPayload.Clone();
tamperedPayload[0] ^= 0xFF;
// Act
var result = plugin.VerifyWithResult(tamperedPayload, signature);
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().NotBeNullOrEmpty();
result.ErrorMessage.Should().NotBeNullOrEmpty();
_output.WriteLine($" {plugin.Name}:");
_output.WriteLine($" Code: {result.ErrorCode}");
_output.WriteLine($" Message: {result.ErrorMessage}");
}
}
#endregion
#region Multi-Plugin Tampering Tests
[Fact]
public void AllPlugins_DetectTampering()
{
// Arrange
var plugins = GetAllPlugins();
var originalPayload = CreateCanonicalPayload();
_output.WriteLine("=== Tampering Detection Across Plugins ===");
foreach (var plugin in plugins)
{
// Sign original
var signature = plugin.Sign(originalPayload);
// Create tampered version
var tamperedPayload = (byte[])originalPayload.Clone();
tamperedPayload[tamperedPayload.Length / 2] ^= 0x42;
// Verify
var originalVerifies = plugin.Verify(originalPayload, signature);
var tamperedVerifies = plugin.Verify(tamperedPayload, signature);
_output.WriteLine($" {plugin.Name} ({plugin.Algorithm}):");
_output.WriteLine($" Original: {(originalVerifies ? "" : "")}");
_output.WriteLine($" Tampered: {(tamperedVerifies ? " FAIL" : " Detected")}");
// Assert
originalVerifies.Should().BeTrue($"{plugin.Name} should verify original");
tamperedVerifies.Should().BeFalse($"{plugin.Name} should detect tampering");
}
}
#endregion
#region Signature Tampering Tests
[Fact]
public void TamperedSignature_SingleBitFlip_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var payload = CreateCanonicalPayload();
var signature = plugin.Sign(payload);
// Tamper signature
var tamperedSignature = (byte[])signature.Clone();
tamperedSignature[0] ^= 0x01;
// Act
var originalVerifies = plugin.Verify(payload, signature);
var tamperedVerifies = plugin.Verify(payload, tamperedSignature);
// Assert
originalVerifies.Should().BeTrue();
tamperedVerifies.Should().BeFalse("tampered signature should NOT verify");
_output.WriteLine("✓ Tampered signature detected");
}
[Fact]
public void TamperedSignature_Truncated_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var payload = CreateCanonicalPayload();
var signature = plugin.Sign(payload);
// Truncate signature
var truncatedSignature = signature[..^10];
// Act
var tamperedVerifies = plugin.Verify(payload, truncatedSignature);
// Assert
tamperedVerifies.Should().BeFalse("truncated signature should NOT verify");
_output.WriteLine("✓ Truncated signature detected");
}
[Fact]
public void TamperedSignature_Extended_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var payload = CreateCanonicalPayload();
var signature = plugin.Sign(payload);
// Extend signature
var extendedSignature = new byte[signature.Length + 10];
Array.Copy(signature, extendedSignature, signature.Length);
// Act
var tamperedVerifies = plugin.Verify(payload, extendedSignature);
// Assert
tamperedVerifies.Should().BeFalse("extended signature should NOT verify");
_output.WriteLine("✓ Extended signature detected");
}
[Fact]
public void WrongSignature_DifferentPayload_VerificationFails()
{
// Arrange
var plugin = new Ed25519SimPlugin();
var payload1 = CreateCanonicalPayload();
var payload2 = SerializeToCanonical(CreateStatementWithModifiedDigest());
var signature1 = plugin.Sign(payload1);
var signature2 = plugin.Sign(payload2);
// Act - cross verify
var crossVerify1 = plugin.Verify(payload1, signature2);
var crossVerify2 = plugin.Verify(payload2, signature1);
// Assert
crossVerify1.Should().BeFalse("wrong signature should NOT verify");
crossVerify2.Should().BeFalse("wrong signature should NOT verify");
_output.WriteLine("✓ Wrong signature detected (payload/signature mismatch)");
}
#endregion
#region Helper Classes and Methods
private static byte[] CreateCanonicalPayload()
{
return SerializeToCanonical(CreateStatement());
}
private static object CreateStatement()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass" }
};
}
private static object CreateStatementWithModifiedDigest()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "0000000000000000000000000000000000000000000000000000000000000000" // Modified
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass" }
};
}
private static object CreateStatementWithModifiedSubjectName()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/malicious@1.0.0", // Modified
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass" }
};
}
private static object CreateStatementWithModifiedPredicateType()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
}
},
predicateType = "https://malicious.com/attack/v1", // Modified
predicate = new { result = "pass" }
};
}
private static object CreateStatementWithModifiedPredicate()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "fail" } // Modified
};
}
private static object CreateStatementWithAddedSubject()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
},
new // Added
{
name = "pkg:npm/malicious@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "1111111111111111111111111111111111111111111111111111111111111111"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass" }
};
}
private static object CreateStatementWithMultipleSubjects()
{
return new
{
_type = "https://in-toto.io/Statement/v0.1",
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
},
new
{
name = "pkg:npm/example2@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f"
}
}
},
predicateType = "https://example.com/test/v1",
predicate = new { result = "pass" }
};
}
private static byte[] SerializeToCanonical(object obj)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false
};
var json = JsonSerializer.Serialize(obj, options);
return Encoding.UTF8.GetBytes(json);
}
private static List<ITestCryptoPlugin> GetAllPlugins()
{
return new List<ITestCryptoPlugin>
{
new Ed25519SimPlugin(),
new Es256SimPlugin(),
new GostSimPlugin()
};
}
#endregion
#region Test Plugin Implementations
private record VerificationResult(bool Success, string ErrorCode, string ErrorMessage);
private interface ITestCryptoPlugin
{
string Name { get; }
string Algorithm { get; }
byte[] Sign(byte[] payload);
bool Verify(byte[] payload, byte[] signature);
VerificationResult VerifyWithResult(byte[] payload, byte[] signature);
}
private sealed class Ed25519SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public Ed25519SimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("ed25519-test-key"));
}
public string Name => "BouncyCastle-Ed25519";
public string Algorithm => "Ed25519";
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA512(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
return VerifyWithResult(payload, signature).Success;
}
public VerificationResult VerifyWithResult(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
if (signature.Length != expected.Length)
{
return new VerificationResult(false, TamperErrorCode,
$"{TamperErrorMessage}: signature length mismatch");
}
if (signature.SequenceEqual(expected))
{
return new VerificationResult(true, "", "");
}
return new VerificationResult(false, TamperErrorCode, TamperErrorMessage);
}
}
private sealed class Es256SimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public Es256SimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("ecdsa-p256-test-key"));
}
public string Name => "eIDAS-ECDSA";
public string Algorithm => "ES256";
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA256(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
return VerifyWithResult(payload, signature).Success;
}
public VerificationResult VerifyWithResult(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
if (signature.SequenceEqual(expected))
{
return new VerificationResult(true, "", "");
}
return new VerificationResult(false, TamperErrorCode, TamperErrorMessage);
}
}
private sealed class GostSimPlugin : ITestCryptoPlugin
{
private readonly byte[] _privateKey;
public GostSimPlugin()
{
_privateKey = SHA256.HashData(Encoding.UTF8.GetBytes("gost-test-key"));
}
public string Name => "CryptoPro-GOST";
public string Algorithm => "GOST_R3410_2012_256";
public byte[] Sign(byte[] payload)
{
using var hmac = new HMACSHA256(_privateKey);
return hmac.ComputeHash(payload);
}
public bool Verify(byte[] payload, byte[] signature)
{
return VerifyWithResult(payload, signature).Success;
}
public VerificationResult VerifyWithResult(byte[] payload, byte[] signature)
{
var expected = Sign(payload);
if (signature.SequenceEqual(expected))
{
return new VerificationResult(true, "", "");
}
return new VerificationResult(false, TamperErrorCode, TamperErrorMessage);
}
}
#endregion
}

View File

@@ -0,0 +1,658 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.EfCore.Context;
using StellaOps.Signer.KeyManagement.Entities;
using Xunit;
namespace StellaOps.Signer.Tests.KeyManagement;
/// <summary>
/// Unit tests for KeyRotationService.
/// Tests tasks PROOF-KEY-0003 through PROOF-KEY-0006.
/// </summary>
public class KeyRotationServiceTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _service;
private readonly FakeTimeProvider _timeProvider;
public KeyRotationServiceTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
_service = new KeyRotationService(
_dbContext,
NullLogger<KeyRotationService>.Instance,
Options.Create(new KeyRotationOptions
{
DefaultActor = "test-user",
ExpiryWarningDays = 60,
MaxKeyAgeDays = 365,
DeprecatedAlgorithms = ["RSA-2048", "SHA1-RSA"]
}),
_timeProvider);
}
public void Dispose()
{
_dbContext.Dispose();
GC.SuppressFinalize(this);
}
private async Task<TrustAnchorEntity> CreateTestAnchorAsync(
string purlPattern = "pkg:npm/*",
List<string>? allowedKeyIds = null,
List<string>? revokedKeyIds = null)
{
var anchor = new TrustAnchorEntity
{
AnchorId = Guid.NewGuid(),
PurlPattern = purlPattern,
AllowedKeyIds = allowedKeyIds ?? [],
RevokedKeyIds = revokedKeyIds ?? [],
IsActive = true,
CreatedAt = _timeProvider.GetUtcNow(),
UpdatedAt = _timeProvider.GetUtcNow()
};
_dbContext.TrustAnchors.Add(anchor);
await _dbContext.SaveChangesAsync();
return anchor;
}
#region AddKeyAsync Tests (PROOF-KEY-0003)
[Fact]
public async Task AddKeyAsync_NewKey_UpdatesAllowedKeyIds()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1"]);
// Act
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-2",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeTrue();
result.AllowedKeyIds.Should().Contain("key-2");
result.AllowedKeyIds.Should().Contain("key-1");
result.AuditLogId.Should().NotBeNull();
}
[Fact]
public async Task AddKeyAsync_DuplicateKey_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1"]);
// Add the key first
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-dup",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act - try to add same key again
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-dup",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest2\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("already exists");
}
[Fact]
public async Task AddKeyAsync_NonExistentAnchor_ReturnsError()
{
// Act
var result = await _service.AddKeyAsync(Guid.NewGuid(), new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("not found");
}
[Fact]
public async Task AddKeyAsync_CreatesKeyHistory()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = _timeProvider.GetUtcNow().AddDays(365)
});
// Assert
var keyHistory = await _dbContext.KeyHistory
.FirstOrDefaultAsync(k => k.AnchorId == anchor.AnchorId && k.KeyId == "key-1");
keyHistory.Should().NotBeNull();
keyHistory!.Algorithm.Should().Be("Ed25519");
keyHistory.ExpiresAt.Should().NotBeNull();
}
[Fact]
public async Task AddKeyAsync_CreatesAuditLog()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
var auditLog = await _dbContext.KeyAuditLog
.FirstOrDefaultAsync(a => a.LogId == result.AuditLogId);
auditLog.Should().NotBeNull();
auditLog!.Operation.Should().Be(KeyOperation.Add);
auditLog.KeyId.Should().Be("key-1");
auditLog.Actor.Should().Be("test-user");
}
#endregion
#region RevokeKeyAsync Tests (PROOF-KEY-0004)
[Fact]
public async Task RevokeKeyAsync_ExistingKey_MovesToRevokedKeys()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1", "key-2"]);
// Add key to history
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation-complete"
});
// Assert
result.Success.Should().BeTrue();
result.AllowedKeyIds.Should().NotContain("key-1");
result.RevokedKeyIds.Should().Contain("key-1");
result.AuditLogId.Should().NotBeNull();
}
[Fact]
public async Task RevokeKeyAsync_AlreadyRevoked_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "first-revocation"
});
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "second-revocation"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("already revoked");
}
[Fact]
public async Task RevokeKeyAsync_NonExistentKey_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "non-existent", new RevokeKeyRequest
{
Reason = "test"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("not found");
}
[Fact]
public async Task RevokeKeyAsync_SetsRevokedAtTime()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var effectiveAt = _timeProvider.GetUtcNow().AddDays(7);
// Act
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "scheduled-rotation",
EffectiveAt = effectiveAt
});
// Assert
var keyHistory = await _dbContext.KeyHistory
.FirstOrDefaultAsync(k => k.KeyId == "key-1");
keyHistory!.RevokedAt.Should().Be(effectiveAt);
keyHistory.RevokeReason.Should().Be("scheduled-rotation");
}
#endregion
#region CheckKeyValidityAsync Tests (PROOF-KEY-0005)
[Fact]
public async Task CheckKeyValidityAsync_ActiveKey_IsValid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidityAsync_RevokedKeyBeforeRevocation_IsValid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Add key at T0
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var addedAt = _timeProvider.GetUtcNow();
// Advance time and revoke at T+10 days
_timeProvider.Advance(TimeSpan.FromDays(10));
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation"
});
// Check validity at T+5 days (before revocation)
var signedAt = addedAt.AddDays(5);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Revoked); // Key is revoked now but was valid at signedAt
}
[Fact]
public async Task CheckKeyValidityAsync_RevokedKeyAfterRevocation_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Revoke immediately
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "compromised"
});
// Try to verify signature made after revocation
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
result.InvalidReason.Should().Contain("revoked");
}
[Fact]
public async Task CheckKeyValidityAsync_KeyNotYetValid_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Try to verify signature made before key was added
var signedAt = _timeProvider.GetUtcNow().AddDays(-1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.NotYetValid);
}
[Fact]
public async Task CheckKeyValidityAsync_ExpiredKey_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(30);
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = expiresAt
});
// Try to verify signature made after expiry
var signedAt = expiresAt.AddDays(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Expired);
}
[Fact]
public async Task CheckKeyValidityAsync_UnknownKey_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "unknown-key", _timeProvider.GetUtcNow());
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Unknown);
}
#endregion
#region GetRotationWarningsAsync Tests (PROOF-KEY-0006)
[Fact]
public async Task GetRotationWarningsAsync_ExpiringKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(30); // Within 60-day warning window
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "expiring-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = expiresAt
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().ContainSingle();
warnings[0].KeyId.Should().Be("expiring-key");
warnings[0].WarningType.Should().Be(RotationWarningType.ExpiryApproaching);
warnings[0].CriticalAt.Should().Be(expiresAt);
}
[Fact]
public async Task GetRotationWarningsAsync_ExpiredKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(-1); // Already expired
_dbContext.KeyHistory.Add(new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "expired-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _timeProvider.GetUtcNow().AddDays(-30),
ExpiresAt = expiresAt,
CreatedAt = _timeProvider.GetUtcNow().AddDays(-30)
});
await _dbContext.SaveChangesAsync();
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "expired-key" && w.WarningType == RotationWarningType.ExpiryApproaching);
}
[Fact]
public async Task GetRotationWarningsAsync_LongLivedKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Key added 400 days ago (exceeds 365-day max)
_dbContext.KeyHistory.Add(new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "old-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _timeProvider.GetUtcNow().AddDays(-400),
CreatedAt = _timeProvider.GetUtcNow().AddDays(-400)
});
await _dbContext.SaveChangesAsync();
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "old-key" && w.WarningType == RotationWarningType.LongLived);
}
[Fact]
public async Task GetRotationWarningsAsync_DeprecatedAlgorithm_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "weak-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "RSA-2048" // Deprecated algorithm
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "weak-key" && w.WarningType == RotationWarningType.AlgorithmDeprecating);
}
[Fact]
public async Task GetRotationWarningsAsync_NoIssues_ReturnsEmpty()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "healthy-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = _timeProvider.GetUtcNow().AddDays(365) // Far in future
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().BeEmpty();
}
[Fact]
public async Task GetRotationWarningsAsync_RevokedKeys_NotIncluded()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "revoked-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "RSA-2048" // Deprecated but revoked
});
await _service.RevokeKeyAsync(anchor.AnchorId, "revoked-key", new RevokeKeyRequest
{
Reason = "rotation"
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().NotContain(w => w.KeyId == "revoked-key");
}
#endregion
#region GetKeyHistoryAsync Tests
[Fact]
public async Task GetKeyHistoryAsync_ReturnsOrderedHistory()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest1\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
_timeProvider.Advance(TimeSpan.FromDays(1));
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-2",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest2\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var history = await _service.GetKeyHistoryAsync(anchor.AnchorId);
// Assert
history.Should().HaveCount(2);
history[0].KeyId.Should().Be("key-2"); // Most recent first
history[1].KeyId.Should().Be("key-1");
}
#endregion
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset initialTime)
{
_now = initialTime;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetTime(DateTimeOffset time) => _now = time;
}

View File

@@ -0,0 +1,408 @@
// -----------------------------------------------------------------------------
// TemporalKeyVerificationTests.cs
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
// Task: PROOF-KEY-0014 - Temporal verification tests (key valid at time T)
// Description: Tests verifying key validity at specific points in time
// -----------------------------------------------------------------------------
using System;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.EfCore.Context;
using StellaOps.Signer.KeyManagement.Entities;
using Xunit;
namespace StellaOps.Signer.Tests.KeyManagement;
/// <summary>
/// Temporal key verification tests.
/// Validates that keys are correctly checked for validity at specific points in time.
/// This is critical for verifying historical proofs that were signed before key rotation.
/// </summary>
public class TemporalKeyVerificationTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _service;
private readonly FakeTimeProvider _timeProvider;
// Timeline:
// 2024-01-15: key-2024 added
// 2024-06-15: key-2025 added (overlap period begins)
// 2025-01-15: key-2024 revoked (overlap period ends)
// 2025-06-15: current time
private readonly DateTimeOffset _key2024AddedAt = new(2024, 1, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _key2025AddedAt = new(2024, 6, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _key2024RevokedAt = new(2025, 1, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _currentTime = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
public TemporalKeyVerificationTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TemporalTestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(_currentTime);
_service = new KeyRotationService(
_dbContext,
NullLogger<KeyRotationService>.Instance,
Options.Create(new KeyRotationOptions
{
DefaultActor = "test-user",
ExpiryWarningDays = 60,
MaxKeyAgeDays = 365,
DeprecatedAlgorithms = ["RSA-2048", "SHA1-RSA"]
}),
_timeProvider);
}
public void Dispose()
{
_dbContext.Dispose();
GC.SuppressFinalize(this);
}
#region Key Lifecycle Timeline Tests
[Fact]
public async Task CheckKeyValidity_KeyNotYetAdded_ReturnsNotYetValid()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
var beforeKeyAdded = _key2024AddedAt.AddDays(-30); // Dec 2023
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", beforeKeyAdded);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.NotYetValid);
result.InvalidReason.Should().Contain("Key was added");
}
[Fact]
public async Task CheckKeyValidity_KeyActiveNoRevocation_ReturnsValid()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
var duringActiveWindow = _key2025AddedAt.AddMonths(1); // July 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", duringActiveWindow);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
result.AddedAt.Should().Be(_key2025AddedAt);
}
[Fact]
public async Task CheckKeyValidity_KeyRevokedButSignedBefore_ReturnsValid()
{
// Arrange - proof was signed during overlap period before key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedDuringOverlap = _key2024RevokedAt.AddDays(-30); // Dec 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", signedDuringOverlap);
// Assert - key-2024 should be valid because signature was made before revocation
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Revoked);
}
[Fact]
public async Task CheckKeyValidity_KeyRevokedAndSignedAfter_ReturnsRevoked()
{
// Arrange - proof was signed after key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedAfterRevocation = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", signedAfterRevocation);
// Assert - key-2024 should be invalid because signature was made after revocation
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
result.RevokedAt.Should().Be(_key2024RevokedAt);
}
[Fact]
public async Task CheckKeyValidity_NewKeyAfterOldRevoked_ReturnsValid()
{
// Arrange - proof was signed with key-2025 after key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedWithNewKey = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", signedWithNewKey);
// Assert - key-2025 should be valid
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
result.AddedAt.Should().Be(_key2025AddedAt);
}
#endregion
#region Overlap Period Tests
[Fact]
public async Task CheckKeyValidity_BothKeysValidDuringOverlap_BothReturnValid()
{
// Arrange - during overlap period (Jun 2024 - Jan 2025), both keys should be valid
var anchor = await CreateTestAnchorWithTimelineAsync();
var duringOverlap = new DateTimeOffset(2024, 9, 15, 0, 0, 0, TimeSpan.Zero); // Sep 2024
// Act
var result2024 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", duringOverlap);
var result2025 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", duringOverlap);
// Assert - both keys should be valid during overlap
result2024.IsValid.Should().BeTrue();
result2024.Status.Should().Be(KeyStatus.Revoked);
result2025.IsValid.Should().BeTrue();
result2025.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidity_ExactlyAtRevocationTime_ReturnsRevoked()
{
// Arrange - checking exactly at the moment of revocation
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act - at exact revocation time, key is already revoked
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", _key2024RevokedAt);
// Assert - at revocation time, key should be considered revoked
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
}
[Fact]
public async Task CheckKeyValidity_OneMillisecondBeforeRevocation_ReturnsValid()
{
// Arrange - one millisecond before revocation
var anchor = await CreateTestAnchorWithTimelineAsync();
var justBeforeRevocation = _key2024RevokedAt.AddMilliseconds(-1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", justBeforeRevocation);
// Assert - key should still be valid
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Revoked);
}
#endregion
#region Key Expiry Tests
[Fact]
public async Task CheckKeyValidity_KeyExpiredButSignedBefore_ReturnsValid()
{
// Arrange - key with expiry date
var anchor = await CreateTestAnchorWithExpiringKeyAsync();
var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
var signedBeforeExpiry = expiryDate.AddDays(-30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedBeforeExpiry);
// Assert - should be valid because signed before expiry
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidity_KeyExpiredAndSignedAfter_ReturnsExpired()
{
// Arrange - key with expiry date
var anchor = await CreateTestAnchorWithExpiringKeyAsync();
var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
var signedAfterExpiry = expiryDate.AddDays(30); // April 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedAfterExpiry);
// Assert - should be invalid because signed after expiry
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Expired);
}
#endregion
#region Unknown Key Tests
[Fact]
public async Task CheckKeyValidity_UnknownKey_ReturnsUnknown()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "nonexistent-key", _currentTime);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Unknown);
result.InvalidReason.Should().Contain("not found");
}
[Fact]
public async Task CheckKeyValidity_UnknownAnchor_ThrowsKeyNotFoundException()
{
// Arrange
var unknownAnchorId = Guid.NewGuid();
// Act
var result = await _service.CheckKeyValidityAsync(unknownAnchorId, "any-key", _currentTime);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Unknown);
}
#endregion
#region Determinism Tests
[Fact]
public async Task CheckKeyValidity_SameInputs_ReturnsSameResult()
{
// Arrange - determinism is critical for audit verification
var anchor = await CreateTestAnchorWithTimelineAsync();
var checkTime = new DateTimeOffset(2024, 9, 15, 10, 30, 45, TimeSpan.Zero);
// Act - call multiple times
var result1 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime);
var result2 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime);
var result3 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime);
// Assert - all results should be identical
result1.Should().BeEquivalentTo(result2);
result2.Should().BeEquivalentTo(result3);
}
[Fact]
public async Task CheckKeyValidity_DifferentTimezones_SameUtcTime_ReturnsSameResult()
{
// Arrange - different timezone representations of same moment
var anchor = await CreateTestAnchorWithTimelineAsync();
var utcTime = new DateTimeOffset(2024, 9, 15, 12, 0, 0, TimeSpan.Zero);
var pstTime = new DateTimeOffset(2024, 9, 15, 4, 0, 0, TimeSpan.FromHours(-8));
var jstTime = new DateTimeOffset(2024, 9, 15, 21, 0, 0, TimeSpan.FromHours(9));
// Act
var resultUtc = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", utcTime);
var resultPst = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", pstTime);
var resultJst = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", jstTime);
// Assert - all should return same result (same UTC instant)
resultUtc.IsValid.Should().Be(resultPst.IsValid);
resultPst.IsValid.Should().Be(resultJst.IsValid);
resultUtc.Status.Should().Be(resultPst.Status);
resultPst.Status.Should().Be(resultJst.Status);
}
#endregion
#region Helper Methods
private async Task<TrustAnchorEntity> CreateTestAnchorWithTimelineAsync()
{
var anchor = new TrustAnchorEntity
{
AnchorId = Guid.NewGuid(),
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-2024", "key-2025"],
RevokedKeyIds = ["key-2024"],
PolicyVersion = "v1.0.0",
CreatedAt = _key2024AddedAt,
UpdatedAt = _key2024RevokedAt
};
var keyHistory = new[]
{
new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "key-2024",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-key-2024\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _key2024AddedAt,
RevokedAt = _key2024RevokedAt,
RevokeReason = "annual-rotation",
CreatedAt = _key2024AddedAt
},
new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "key-2025",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-key-2025\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _key2025AddedAt,
RevokedAt = null,
RevokeReason = null,
CreatedAt = _key2025AddedAt
}
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistory.AddRange(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
}
private async Task<TrustAnchorEntity> CreateTestAnchorWithExpiringKeyAsync()
{
var anchor = new TrustAnchorEntity
{
AnchorId = Guid.NewGuid(),
PurlPattern = "pkg:pypi/*",
AllowedKeyIds = ["expiring-key"],
RevokedKeyIds = [],
PolicyVersion = "v1.0.0",
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
var keyHistory = new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "expiring-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-expiring-key\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero),
RevokedAt = null,
RevokeReason = null,
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistory.Add(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
}
#endregion
}
// Note: FakeTimeProvider is defined in KeyRotationServiceTests.cs

View File

@@ -0,0 +1,506 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.EfCore.Context;
using StellaOps.Signer.KeyManagement.Entities;
using Xunit;
namespace StellaOps.Signer.Tests.KeyManagement;
/// <summary>
/// Tests for TrustAnchorManager and PURL pattern matching.
/// Tests tasks PROOF-KEY-0008 (PURL pattern matching) and PROOF-KEY-0009 (signature verification).
/// </summary>
public class TrustAnchorManagerTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _rotationService;
private readonly TrustAnchorManager _manager;
private readonly FakeTimeProvider _timeProvider;
public TrustAnchorManagerTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
_rotationService = new KeyRotationService(
_dbContext,
NullLogger<KeyRotationService>.Instance,
Options.Create(new KeyRotationOptions()),
_timeProvider);
_manager = new TrustAnchorManager(
_dbContext,
_rotationService,
NullLogger<TrustAnchorManager>.Instance,
_timeProvider);
}
public void Dispose()
{
_dbContext.Dispose();
GC.SuppressFinalize(this);
}
#region PURL Pattern Matching Tests (PROOF-KEY-0008)
[Theory]
[InlineData("pkg:npm/*", true)]
[InlineData("pkg:maven/org.apache/*", true)]
[InlineData("pkg:npm/lodash", true)]
[InlineData("pkg:pypi/requests@2.28.0", true)]
[InlineData("npm/*", false)] // Missing pkg: prefix
[InlineData("pkg:", false)] // Missing type
[InlineData("", false)]
[InlineData(null, false)]
public void IsValidPattern_ValidatesCorrectly(string? pattern, bool expected)
{
PurlPatternMatcher.IsValidPattern(pattern!).Should().Be(expected);
}
[Theory]
[InlineData("pkg:npm/*", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/*", "pkg:npm/@scope/package@1.0.0", true)]
[InlineData("pkg:npm/*", "pkg:pypi/requests@2.28.0", false)]
[InlineData("pkg:maven/org.apache/*", "pkg:maven/org.apache/commons-lang3@3.12.0", true)]
[InlineData("pkg:maven/org.apache/*", "pkg:maven/com.google/guava@31.0", false)]
[InlineData("pkg:npm/lodash", "pkg:npm/lodash", true)]
[InlineData("pkg:npm/lodash", "pkg:npm/lodash@4.17.21", false)] // Exact match only
[InlineData("pkg:npm/lodash*", "pkg:npm/lodash@4.17.21", true)] // Wildcard at end
public void Matches_EvaluatesCorrectly(string pattern, string purl, bool expected)
{
PurlPatternMatcher.Matches(pattern, purl).Should().Be(expected);
}
[Theory]
[InlineData("pkg:npm/*", 15)] // 2 segments * 10 - 1 wildcard * 5 = 15
[InlineData("pkg:maven/org.apache/*", 25)] // 3 segments * 10 - 1 wildcard * 5 = 25
[InlineData("pkg:npm/lodash", 20)] // 2 segments * 10 - 0 wildcards = 20
[InlineData("*", 5)] // 1 segment * 10 - 1 wildcard * 5 = 5
public void GetSpecificity_CalculatesCorrectly(string pattern, int expectedSpecificity)
{
PurlPatternMatcher.GetSpecificity(pattern).Should().Be(expectedSpecificity);
}
[Fact]
public async Task FindAnchorForPurl_SelectsMostSpecificMatch()
{
// Arrange - Create anchors with different specificity
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-npm-general"]
});
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/@myorg/*",
AllowedKeyIds = ["key-npm-myorg"]
});
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/@myorg/specific-package*",
AllowedKeyIds = ["key-npm-specific"]
});
// Act & Assert - Most specific should be selected
var result1 = await _manager.FindAnchorForPurlAsync("pkg:npm/lodash@4.17.21");
result1.Should().NotBeNull();
result1!.AllowedKeyIds.Should().Contain("key-npm-general");
var result2 = await _manager.FindAnchorForPurlAsync("pkg:npm/@myorg/other-package@1.0.0");
result2.Should().NotBeNull();
result2!.AllowedKeyIds.Should().Contain("key-npm-myorg");
var result3 = await _manager.FindAnchorForPurlAsync("pkg:npm/@myorg/specific-package@2.0.0");
result3.Should().NotBeNull();
result3!.AllowedKeyIds.Should().Contain("key-npm-specific");
}
[Fact]
public async Task FindAnchorForPurl_NoMatch_ReturnsNull()
{
// Arrange
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
// Act
var result = await _manager.FindAnchorForPurlAsync("pkg:maven/org.apache/commons@3.0");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task FindAnchorForPurl_InactiveAnchor_NotReturned()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
await _manager.DeactivateAnchorAsync(anchor.AnchorId);
// Act
var result = await _manager.FindAnchorForPurlAsync("pkg:npm/lodash@4.17.21");
// Assert
result.Should().BeNull();
}
#endregion
#region Signature Verification with Key History Tests (PROOF-KEY-0009)
[Fact]
public async Task VerifySignatureAuthorization_ValidKey_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsAuthorized.Should().BeTrue();
result.KeyStatus.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task VerifySignatureAuthorization_RevokedKeyBeforeRevocation_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Advance time and revoke
_timeProvider.Advance(TimeSpan.FromDays(30));
await _rotationService.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation"
});
// Act - Verify signature made before revocation
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert - Should succeed because signature was made before revocation
result.IsAuthorized.Should().BeTrue();
result.KeyStatus.Should().Be(KeyStatus.Revoked); // Key is revoked now
}
[Fact]
public async Task VerifySignatureAuthorization_RevokedKeyAfterRevocation_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Revoke immediately
await _rotationService.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "compromised"
});
// Try to verify signature made after revocation
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsAuthorized.Should().BeFalse();
result.KeyStatus.Should().Be(KeyStatus.Revoked);
result.FailureReason.Should().Contain("revoked");
}
[Fact]
public async Task VerifySignatureAuthorization_UnknownKey_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "unknown-key", _timeProvider.GetUtcNow());
// Assert
result.IsAuthorized.Should().BeFalse();
result.KeyStatus.Should().Be(KeyStatus.Unknown);
}
[Fact]
public async Task VerifySignatureAuthorization_PredicateTypeAllowed_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = ["evidence.stella/v1", "reasoning.stella/v1"]
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "evidence.stella/v1");
// Assert
result.IsAuthorized.Should().BeTrue();
result.PredicateTypeAllowed.Should().BeTrue();
}
[Fact]
public async Task VerifySignatureAuthorization_PredicateTypeNotAllowed_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = ["evidence.stella/v1"] // Only evidence allowed
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "vex.stella/v1");
// Assert
result.IsAuthorized.Should().BeFalse();
result.PredicateTypeAllowed.Should().BeFalse();
result.FailureReason.Should().Contain("not allowed");
}
[Fact]
public async Task VerifySignatureAuthorization_NoPredicateRestriction_AllAllowed()
{
// Arrange - No AllowedPredicateTypes means all are allowed
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = null
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "any.predicate/v1");
// Assert
result.IsAuthorized.Should().BeTrue();
result.PredicateTypeAllowed.Should().BeTrue();
}
#endregion
#region CRUD Operations Tests
[Fact]
public async Task CreateAnchor_ValidRequest_Succeeds()
{
// Act
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1", "key-2"],
AllowedPredicateTypes = ["evidence.stella/v1"],
PolicyRef = "policy-001",
PolicyVersion = "v1.0"
});
// Assert
anchor.Should().NotBeNull();
anchor.AnchorId.Should().NotBeEmpty();
anchor.PurlPattern.Should().Be("pkg:npm/*");
anchor.AllowedKeyIds.Should().Contain(["key-1", "key-2"]);
anchor.AllowedPredicateTypes.Should().Contain("evidence.stella/v1");
anchor.IsActive.Should().BeTrue();
}
[Fact]
public async Task GetAnchor_Exists_ReturnsAnchor()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
// Act
var anchor = await _manager.GetAnchorAsync(created.AnchorId);
// Assert
anchor.Should().NotBeNull();
anchor!.AnchorId.Should().Be(created.AnchorId);
}
[Fact]
public async Task GetAnchor_NotExists_ReturnsNull()
{
// Act
var anchor = await _manager.GetAnchorAsync(Guid.NewGuid());
// Assert
anchor.Should().BeNull();
}
[Fact]
public async Task UpdateAnchor_ValidRequest_UpdatesFields()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
PolicyVersion = "v1.0"
});
_timeProvider.Advance(TimeSpan.FromMinutes(1));
// Act
var updated = await _manager.UpdateAnchorAsync(created.AnchorId, new UpdateTrustAnchorRequest
{
PolicyVersion = "v2.0",
AllowedPredicateTypes = ["new.predicate/v1"]
});
// Assert
updated.PolicyVersion.Should().Be("v2.0");
updated.AllowedPredicateTypes.Should().Contain("new.predicate/v1");
updated.UpdatedAt.Should().BeAfter(created.CreatedAt);
}
[Fact]
public async Task DeactivateAnchor_SetsInactive()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
// Act
await _manager.DeactivateAnchorAsync(created.AnchorId);
// Assert
var anchor = await _manager.GetAnchorAsync(created.AnchorId);
anchor!.IsActive.Should().BeFalse();
}
[Fact]
public async Task GetActiveAnchors_ReturnsOnlyActive()
{
// Arrange
var active1 = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
var inactive = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:pypi/*",
AllowedKeyIds = []
});
await _manager.DeactivateAnchorAsync(inactive.AnchorId);
var active2 = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:maven/*",
AllowedKeyIds = []
});
// Act
var anchors = await _manager.GetActiveAnchorsAsync();
// Assert
anchors.Should().HaveCount(2);
anchors.Should().Contain(a => a.AnchorId == active1.AnchorId);
anchors.Should().Contain(a => a.AnchorId == active2.AnchorId);
anchors.Should().NotContain(a => a.AnchorId == inactive.AnchorId);
}
#endregion
}

View File

@@ -0,0 +1,562 @@
// -----------------------------------------------------------------------------
// CertificateChainValidatorTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0016 - Unit tests for Certificate chain validation
// Description: Tests for validating Fulcio certificate chains and identity
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
public sealed class CertificateChainValidatorTests : IDisposable
{
private readonly SignerKeylessOptions _options;
private readonly IOptions<SignerKeylessOptions> _optionsWrapper;
private readonly FakeTimeProvider _timeProvider;
private readonly List<X509Certificate2> _generatedCerts = [];
public CertificateChainValidatorTests()
{
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_options = new SignerKeylessOptions
{
Enabled = true,
Certificate = new CertificateOptions
{
ValidateChain = true,
RequireSct = false,
RootBundlePath = string.Empty,
AdditionalRoots = []
},
Identity = new IdentityOptions
{
ExpectedIssuers = [],
ExpectedSubjectPatterns = []
}
};
_optionsWrapper = Options.Create(_options);
}
public void Dispose()
{
foreach (var cert in _generatedCerts)
{
cert.Dispose();
}
}
[Fact]
public async Task ValidateAsync_ValidChain_ReturnsSuccess()
{
// Arrange
var (root, intermediate, leaf) = CreateValidCertificateChain();
_options.Certificate.AdditionalRoots.Add(ExportToPem(root));
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = await validator.ValidateAsync(
leaf.RawData,
[intermediate.RawData]);
// Assert
result.IsValid.Should().BeTrue(result.ErrorMessage ?? "expected chain to be valid");
result.ErrorMessage.Should().BeNull();
}
[Fact]
public async Task ValidateAsync_ExpiredCertificate_ReturnsFailure()
{
// Arrange
var (root, intermediate, leaf) = CreateCertificateChainWithExpiredLeaf();
_options.Certificate.AdditionalRoots.Add(ExportToPem(root));
// Set time to after certificate expiry
_timeProvider.SetUtcNow(DateTimeOffset.UtcNow.AddDays(30));
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = await validator.ValidateAsync(
leaf.RawData,
[intermediate.RawData]);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("expired");
}
[Fact]
public async Task ValidateAsync_NotYetValidCertificate_ReturnsFailure()
{
// Arrange
var (root, intermediate, leaf) = CreateCertificateChainWithFutureLeaf();
_options.Certificate.AdditionalRoots.Add(ExportToPem(root));
// Set time to before certificate validity
_timeProvider.SetUtcNow(DateTimeOffset.UtcNow.AddDays(-30));
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = await validator.ValidateAsync(
leaf.RawData,
[intermediate.RawData]);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("not yet valid");
}
[Fact]
public async Task ValidateAsync_UntrustedRoot_ReturnsFailureWhenValidationEnabled()
{
// Arrange - don't add root to trusted roots
var (_, intermediate, leaf) = CreateValidCertificateChain();
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = await validator.ValidateAsync(
leaf.RawData,
[intermediate.RawData]);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("validation failed");
}
[Fact]
public async Task ValidateAsync_NullLeafCertificate_ThrowsArgumentNullException()
{
// Arrange
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var act = async () => await validator.ValidateAsync(null!, []);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ValidateAsync_NullChain_ThrowsArgumentNullException()
{
// Arrange
var (_, _, leaf) = CreateValidCertificateChain();
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var act = async () => await validator.ValidateAsync(leaf.RawData, null!);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ValidateAsync_InvalidCertificateData_ReturnsFailure()
{
// Arrange
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
var invalidData = new byte[] { 1, 2, 3, 4, 5 };
// Act
var result = await validator.ValidateAsync(invalidData, []);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("error");
}
[Fact]
public void ValidateIdentity_ValidCertificate_ReturnsSuccess()
{
// Arrange
var cert = CreateCertificateWithFulcioExtensions("https://test.auth", "test@test.com");
_options.Identity.ExpectedIssuers.Add("https://test.auth");
_options.Identity.ExpectedSubjectPatterns.Add(".*@test\\.com");
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeTrue();
result.Issuer.Should().Be("https://test.auth");
}
[Fact]
public void ValidateIdentity_UnexpectedIssuer_ReturnsFailure()
{
// Arrange
var cert = CreateCertificateWithFulcioExtensions("https://untrusted.auth", "test@test.com");
_options.Identity.ExpectedIssuers.Add("https://trusted.auth");
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("not in the expected issuers list");
}
[Fact]
public void ValidateIdentity_SubjectNotMatchingPattern_ReturnsFailure()
{
// Arrange
var cert = CreateCertificateWithFulcioExtensions("https://test.auth", "bad@evil.com");
_options.Identity.ExpectedSubjectPatterns.Add(".*@trusted\\.com");
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("does not match any expected pattern");
}
[Fact]
public void ValidateIdentity_NoExpectedIssuersConfigured_AcceptsAnyIssuer()
{
// Arrange
var cert = CreateCertificateWithFulcioExtensions("https://any.auth", "test@test.com");
// Leave ExpectedIssuers empty
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeTrue();
result.Issuer.Should().Be("https://any.auth");
}
[Fact]
public void ValidateIdentity_NoSubjectPatternsConfigured_AcceptsAnySubject()
{
// Arrange
var cert = CreateCertificateWithFulcioExtensions("https://test.auth", "any@any.com");
// Leave ExpectedSubjectPatterns empty
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ValidateIdentity_NullCertificate_ThrowsArgumentNullException()
{
// Arrange
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
Action act = () => validator.ValidateIdentity(null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void ValidateIdentity_CertificateWithoutOidcIssuer_ReturnsFailure()
{
// Arrange - create a cert without Fulcio OIDC issuer extension
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=Test",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(10));
var validator = new CertificateChainValidator(
_optionsWrapper,
NullLogger<CertificateChainValidator>.Instance,
_timeProvider);
// Act
var result = validator.ValidateIdentity(cert);
// Assert
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Contain("OIDC issuer extension");
}
// Helper methods for certificate generation
private (X509Certificate2 Root, X509Certificate2 Intermediate, X509Certificate2 Leaf) CreateValidCertificateChain()
{
// Create CA root
using var rootKey = RSA.Create(2048);
var rootRequest = new CertificateRequest(
"CN=Test Root CA, O=Test",
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var root = rootRequest.CreateSelfSigned(
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(10));
_generatedCerts.Add(root);
// Create intermediate
using var intermediateKey = RSA.Create(2048);
var intermediateRequest = new CertificateRequest(
"CN=Test Intermediate CA, O=Test",
intermediateKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var intermediateSerial = new byte[16];
RandomNumberGenerator.Fill(intermediateSerial);
var intermediate = intermediateRequest.Create(
root,
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(5),
intermediateSerial);
_generatedCerts.Add(intermediate);
// Create leaf
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Test Leaf, O=Test",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var leafSerial = new byte[16];
RandomNumberGenerator.Fill(leafSerial);
var leaf = leafRequest.Create(
intermediate.CopyWithPrivateKey(intermediateKey),
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(10),
leafSerial);
_generatedCerts.Add(leaf);
return (root, intermediate, leaf);
}
private (X509Certificate2 Root, X509Certificate2 Intermediate, X509Certificate2 Leaf) CreateCertificateChainWithExpiredLeaf()
{
using var rootKey = RSA.Create(2048);
var rootRequest = new CertificateRequest(
"CN=Test Root CA, O=Test",
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var root = rootRequest.CreateSelfSigned(
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(10));
_generatedCerts.Add(root);
using var intermediateKey = RSA.Create(2048);
var intermediateRequest = new CertificateRequest(
"CN=Test Intermediate CA, O=Test",
intermediateKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var intermediateSerial = new byte[16];
RandomNumberGenerator.Fill(intermediateSerial);
var intermediate = intermediateRequest.Create(
root,
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(5),
intermediateSerial);
_generatedCerts.Add(intermediate);
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Test Leaf, O=Test",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var leafSerial = new byte[16];
RandomNumberGenerator.Fill(leafSerial);
// Expired leaf
var leaf = leafRequest.Create(
intermediate.CopyWithPrivateKey(intermediateKey),
DateTimeOffset.UtcNow.AddDays(-10),
DateTimeOffset.UtcNow.AddDays(-1), // Already expired
leafSerial);
_generatedCerts.Add(leaf);
return (root, intermediate, leaf);
}
private (X509Certificate2 Root, X509Certificate2 Intermediate, X509Certificate2 Leaf) CreateCertificateChainWithFutureLeaf()
{
using var rootKey = RSA.Create(2048);
var rootRequest = new CertificateRequest(
"CN=Test Root CA, O=Test",
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var root = rootRequest.CreateSelfSigned(
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(10));
_generatedCerts.Add(root);
using var intermediateKey = RSA.Create(2048);
var intermediateRequest = new CertificateRequest(
"CN=Test Intermediate CA, O=Test",
intermediateKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
var intermediateSerial = new byte[16];
RandomNumberGenerator.Fill(intermediateSerial);
var intermediate = intermediateRequest.Create(
root,
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(5),
intermediateSerial);
_generatedCerts.Add(intermediate);
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Test Leaf, O=Test",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var leafSerial = new byte[16];
RandomNumberGenerator.Fill(leafSerial);
// Future leaf - not yet valid
var leaf = leafRequest.Create(
intermediate.CopyWithPrivateKey(intermediateKey),
DateTimeOffset.UtcNow.AddDays(10), // Starts in the future
DateTimeOffset.UtcNow.AddDays(20),
leafSerial);
_generatedCerts.Add(leaf);
return (root, intermediate, leaf);
}
private X509Certificate2 CreateCertificateWithFulcioExtensions(string issuer, string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={subject}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add Fulcio OIDC issuer extension (OID: 1.3.6.1.4.1.57264.1.1)
var issuerOid = new Oid("1.3.6.1.4.1.57264.1.1");
var issuerBytes = System.Text.Encoding.UTF8.GetBytes(issuer);
var issuerExtension = new X509Extension(issuerOid, issuerBytes, false);
request.CertificateExtensions.Add(issuerExtension);
// Add SAN extension with email
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddEmailAddress(subject);
request.CertificateExtensions.Add(sanBuilder.Build());
var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(10));
_generatedCerts.Add(cert);
return cert;
}
private static string ExportToPem(X509Certificate2 cert)
{
return $"-----BEGIN CERTIFICATE-----\n{Convert.ToBase64String(cert.RawData)}\n-----END CERTIFICATE-----";
}
/// <summary>
/// Fake time provider for testing time-dependent logic.
/// </summary>
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
}
}

View File

@@ -0,0 +1,247 @@
// -----------------------------------------------------------------------------
// EphemeralKeyGeneratorTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0013 - Unit tests for EphemeralKeyGenerator
// Description: Tests for ephemeral key generation and secure disposal
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
public sealed class EphemeralKeyGeneratorTests
{
private readonly EphemeralKeyGenerator _generator = new(NullLogger<EphemeralKeyGenerator>.Instance);
[Fact]
public void Generate_EcdsaP256_ReturnsValidKeyPair()
{
// Act
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
// Assert
keyPair.Should().NotBeNull();
keyPair.Algorithm.Should().Be(KeylessAlgorithms.EcdsaP256);
keyPair.PublicKey.IsEmpty.Should().BeFalse();
keyPair.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Generate_EcdsaP256_ReturnsSpkiPublicKey()
{
// Act
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
// Assert - SPKI format for P-256 is typically 91 bytes
keyPair.PublicKey.Length.Should().BeGreaterThan(60);
}
[Fact]
public void Generate_Ed25519_ThrowsNotImplemented()
{
// Act
var act = () => _generator.Generate(KeylessAlgorithms.Ed25519);
// Assert - Ed25519 is not yet implemented
act.Should().Throw<EphemeralKeyGenerationException>()
.WithMessage("*Ed25519*");
}
[Fact]
public void Generate_UnsupportedAlgorithm_ThrowsException()
{
// Act
var act = () => _generator.Generate("UNSUPPORTED_ALG");
// Assert
act.Should().Throw<EphemeralKeyGenerationException>()
.WithMessage("*UNSUPPORTED_ALG*");
}
[Fact]
public void Generate_MultipleCalls_ReturnsDifferentKeys()
{
// Act
using var keyPair1 = _generator.Generate(KeylessAlgorithms.EcdsaP256);
using var keyPair2 = _generator.Generate(KeylessAlgorithms.EcdsaP256);
// Assert - Each call should generate a unique key pair
keyPair1.PublicKey.ToArray().Should().NotEqual(keyPair2.PublicKey.ToArray());
}
[Fact]
public void Sign_WithEcdsaP256Key_ProducesValidSignature()
{
// Arrange
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
var data = "Test data to sign"u8.ToArray();
// Act
var signature = keyPair.Sign(data);
// Assert
signature.Should().NotBeNullOrEmpty();
signature.Length.Should().BeGreaterThan(0);
}
[Fact]
public void Sign_DifferentData_ProducesDifferentSignatures()
{
// Arrange
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
var data1 = "First message"u8.ToArray();
var data2 = "Second message"u8.ToArray();
// Act
var signature1 = keyPair.Sign(data1);
var signature2 = keyPair.Sign(data2);
// Assert
signature1.Should().NotEqual(signature2);
}
[Fact]
public void Dispose_KeyPair_AllowsPublicKeyAccess()
{
// Arrange
var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
var publicKeyBefore = keyPair.PublicKey.ToArray();
// Act
keyPair.Dispose();
// Assert - Public key should still be accessible after dispose
keyPair.PublicKey.ToArray().Should().Equal(publicKeyBefore);
}
[Fact]
public void Sign_AfterDispose_ThrowsException()
{
// Arrange
var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
keyPair.Dispose();
var data = "Test data"u8.ToArray();
// Act
var act = () => keyPair.Sign(data);
// Assert
act.Should().Throw<ObjectDisposedException>();
}
[Fact]
public void Generate_NullAlgorithm_ThrowsException()
{
// Act
var act = () => _generator.Generate(null!);
// Assert
act.Should().Throw<Exception>(); // Either ArgumentNullException or EphemeralKeyGenerationException
}
[Fact]
public void Generate_EmptyAlgorithm_ThrowsException()
{
// Act
var act = () => _generator.Generate(string.Empty);
// Assert
act.Should().Throw<EphemeralKeyGenerationException>();
}
[Fact]
public void Sign_EmptyData_ProducesValidSignature()
{
// Arrange
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
var emptyData = Array.Empty<byte>();
// Act
var signature = keyPair.Sign(emptyData);
// Assert - Should still produce a valid signature
signature.Should().NotBeNullOrEmpty();
}
[Fact]
public void Sign_LargeData_ProducesValidSignature()
{
// Arrange
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
var largeData = new byte[1024 * 1024]; // 1 MB
Random.Shared.NextBytes(largeData);
// Act
var signature = keyPair.Sign(largeData);
// Assert
signature.Should().NotBeNullOrEmpty();
}
[Fact]
public void PrivateKey_AfterDispose_ThrowsException()
{
// Arrange
var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
keyPair.Dispose();
// Act
Action act = () => _ = keyPair.PrivateKey;
// Assert
act.Should().Throw<ObjectDisposedException>();
}
[Fact]
public void PrivateKey_BeforeDispose_IsAccessible()
{
// Arrange
using var keyPair = _generator.Generate(KeylessAlgorithms.EcdsaP256);
// Act & Assert
keyPair.PrivateKey.IsEmpty.Should().BeFalse();
}
}
/// <summary>
/// Tests for KeylessAlgorithms constants.
/// </summary>
public sealed class KeylessAlgorithmsTests
{
[Fact]
public void EcdsaP256_HasCorrectValue()
{
KeylessAlgorithms.EcdsaP256.Should().Be("ECDSA_P256");
}
[Fact]
public void Ed25519_HasCorrectValue()
{
KeylessAlgorithms.Ed25519.Should().Be("Ed25519");
}
[Fact]
public void IsSupported_ValidAlgorithm_ReturnsTrue()
{
KeylessAlgorithms.IsSupported(KeylessAlgorithms.EcdsaP256).Should().BeTrue();
KeylessAlgorithms.IsSupported(KeylessAlgorithms.Ed25519).Should().BeTrue();
}
[Fact]
public void IsSupported_InvalidAlgorithm_ReturnsFalse()
{
KeylessAlgorithms.IsSupported("RSA_2048").Should().BeFalse();
KeylessAlgorithms.IsSupported("UNKNOWN").Should().BeFalse();
}
[Fact]
public void IsSupported_CaseInsensitive()
{
KeylessAlgorithms.IsSupported("ecdsa_p256").Should().BeTrue();
KeylessAlgorithms.IsSupported("ed25519").Should().BeTrue();
}
}

View File

@@ -0,0 +1,481 @@
// -----------------------------------------------------------------------------
// HttpFulcioClientTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0014 - Unit tests for HttpFulcioClient (mocked)
// Description: Tests for HTTP client interactions with Fulcio CA
// -----------------------------------------------------------------------------
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
public sealed class HttpFulcioClientTests
{
private readonly SignerKeylessOptions _options;
private readonly IOptions<SignerKeylessOptions> _optionsWrapper;
public HttpFulcioClientTests()
{
_options = new SignerKeylessOptions
{
Enabled = true,
Fulcio = new FulcioOptions
{
Url = "https://fulcio.test",
Timeout = TimeSpan.FromSeconds(30),
Retries = 3,
BackoffBase = TimeSpan.FromMilliseconds(100),
BackoffMax = TimeSpan.FromSeconds(5)
},
Algorithms = new AlgorithmOptions
{
Preferred = KeylessAlgorithms.EcdsaP256,
Allowed = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519]
}
};
_optionsWrapper = Options.Create(_options);
}
[Fact]
public async Task GetCertificateAsync_SuccessfulResponse_ReturnsCertificateResult()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var result = await client.GetCertificateAsync(request);
// Assert
result.Should().NotBeNull();
result.Certificate.Should().NotBeEmpty();
result.CertificateChain.Should().NotBeEmpty();
result.Identity.Should().NotBeNull();
}
[Fact]
public async Task GetCertificateAsync_SuccessfulResponse_ExtractsNotBeforeAndNotAfter()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var result = await client.GetCertificateAsync(request);
// Assert
result.NotBefore.Should().BeBefore(result.NotAfter);
result.Validity.Should().BeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task GetCertificateAsync_BadRequest_ThrowsWithoutRetry()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\": \"Invalid request\"}")
};
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.HttpStatus == 400);
callCount.Should().Be(1, "Bad requests should not be retried");
}
[Fact]
public async Task GetCertificateAsync_Unauthorized_ThrowsWithoutRetry()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("{\"error\": \"Invalid token\"}")
};
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.HttpStatus == 401);
callCount.Should().Be(1, "Unauthorized requests should not be retried");
}
[Fact]
public async Task GetCertificateAsync_Forbidden_ThrowsWithoutRetry()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
return new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("{\"error\": \"Access denied\"}")
};
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.HttpStatus == 403);
callCount.Should().Be(1, "Forbidden requests should not be retried");
}
[Fact]
public async Task GetCertificateAsync_ServiceUnavailable_RetriesWithBackoff()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
if (callCount < 3)
{
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("{\"error\": \"Service unavailable\"}")
};
}
return CreateSuccessfulFulcioResponse();
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var result = await client.GetCertificateAsync(request);
// Assert
result.Should().NotBeNull();
callCount.Should().Be(3, "Should retry until success");
}
[Fact]
public async Task GetCertificateAsync_AllRetriesFail_ThrowsException()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("{\"error\": \"Service unavailable\"}")
};
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.Message.Contains("after 3 attempts"));
callCount.Should().Be(3, "Should exhaust all retries");
}
[Fact]
public async Task GetCertificateAsync_NetworkError_RetriesWithBackoff()
{
// Arrange
var callCount = 0;
var handler = new MockHttpMessageHandler(_ =>
{
callCount++;
if (callCount < 3)
{
throw new HttpRequestException("Network error");
}
return CreateSuccessfulFulcioResponse();
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var result = await client.GetCertificateAsync(request);
// Assert
result.Should().NotBeNull();
callCount.Should().Be(3, "Should retry on network errors");
}
[Fact]
public async Task GetCertificateAsync_EmptyResponse_ThrowsException()
{
// Arrange
var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.Message.Contains("No certificates"));
}
[Fact]
public async Task GetCertificateAsync_EmptyCertificateChain_ThrowsException()
{
// Arrange
var response = new
{
signedCertificateEmbeddedSct = new
{
chain = new { certificates = Array.Empty<string>() }
}
};
var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(response))
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>()
.Where(e => e.Message.Contains("Empty certificate chain"));
}
[Fact]
public async Task GetCertificateAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
var handler = new MockHttpMessageHandler(async _ =>
{
await Task.Delay(5000);
return CreateSuccessfulFulcioResponse();
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
using var cts = new CancellationTokenSource(100);
// Act
var act = async () => await client.GetCertificateAsync(request, cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task GetCertificateAsync_NullPublicKey_ThrowsArgumentException()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = new FulcioCertificateRequest(null!, KeylessAlgorithms.EcdsaP256, "token");
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.Message.Contains("PublicKey"));
}
[Fact]
public async Task GetCertificateAsync_EmptyOidcToken_ThrowsArgumentException()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = new FulcioCertificateRequest(
new byte[] { 1, 2, 3 },
KeylessAlgorithms.EcdsaP256,
string.Empty);
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.Message.Contains("OidcIdentityToken"));
}
[Fact]
public async Task GetCertificateAsync_UnsupportedAlgorithm_ThrowsArgumentException()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = new FulcioCertificateRequest(
new byte[] { 1, 2, 3 },
"UNSUPPORTED",
"token");
// Act
var act = async () => await client.GetCertificateAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.Message.Contains("Unsupported algorithm"));
}
[Fact]
public async Task GetCertificateAsync_IncludesSignedCertificateTimestamp()
{
// Arrange
var handler = new MockHttpMessageHandler(CreateSuccessfulFulcioResponse());
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://fulcio.test") };
var client = new HttpFulcioClient(httpClient, _optionsWrapper, NullLogger<HttpFulcioClient>.Instance);
var request = CreateValidRequest();
// Act
var result = await client.GetCertificateAsync(request);
// Assert
result.SignedCertificateTimestamp.Should().NotBeNullOrEmpty();
}
// Helper methods
private static FulcioCertificateRequest CreateValidRequest()
{
return new FulcioCertificateRequest(
PublicKey: GenerateTestPublicKey(),
Algorithm: KeylessAlgorithms.EcdsaP256,
OidcIdentityToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6InRlc3RAdGVzdC5jb20iLCJleHAiOjk5OTk5OTk5OTl9.sig");
}
private static byte[] GenerateTestPublicKey()
{
using var ecdsa = System.Security.Cryptography.ECDsa.Create(
System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
return ecdsa.ExportSubjectPublicKeyInfo();
}
private static HttpResponseMessage CreateSuccessfulFulcioResponse()
{
// Generate a real self-signed test certificate for realistic testing
using var rsa = System.Security.Cryptography.RSA.Create(2048);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=Test Certificate, O=Test Org",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(10));
var certPem = $"-----BEGIN CERTIFICATE-----\n{Convert.ToBase64String(cert.RawData)}\n-----END CERTIFICATE-----";
var response = new
{
signedCertificateEmbeddedSct = new
{
chain = new
{
certificates = new[] { certPem, certPem } // Leaf + intermediate
},
sct = "test-sct-value"
}
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}))
};
}
/// <summary>
/// Mock HTTP message handler for testing.
/// </summary>
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _handler;
public MockHttpMessageHandler(HttpResponseMessage response)
: this(_ => Task.FromResult(response))
{
}
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
: this(request => Task.FromResult(handler(request)))
{
}
public MockHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _handler(request);
}
}
}

View File

@@ -0,0 +1,401 @@
// -----------------------------------------------------------------------------
// KeylessDsseSignerTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0015 - Unit tests for KeylessDsseSigner
// Description: Tests for keyless DSSE signing with Fulcio certificates
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.Core;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
public sealed class KeylessDsseSignerTests : IDisposable
{
private readonly IEphemeralKeyGenerator _keyGenerator;
private readonly IFulcioClient _fulcioClient;
private readonly IOidcTokenProvider _tokenProvider;
private readonly IOptions<SignerKeylessOptions> _options;
private readonly ILogger<KeylessDsseSigner> _logger;
private readonly KeylessDsseSigner _signer;
// Test data
private readonly byte[] _testCertificate;
private readonly byte[][] _testCertChain;
private const string TestOidcToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6InRlc3RAdGVzdC5jb20iLCJleHAiOjk5OTk5OTk5OTl9.signature";
public KeylessDsseSignerTests()
{
// Generate a self-signed test certificate
_testCertificate = GenerateTestCertificate();
_testCertChain = [GenerateTestCertificate()];
// Use real key generator for realistic tests
_keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
_fulcioClient = Substitute.For<IFulcioClient>();
_tokenProvider = Substitute.For<IOidcTokenProvider>();
_options = Options.Create(new SignerKeylessOptions
{
Enabled = true,
Algorithms = new AlgorithmOptions
{
Preferred = KeylessAlgorithms.EcdsaP256,
Allowed = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519]
}
});
_logger = NullLogger<KeylessDsseSigner>.Instance;
// Configure default mock behavior
_tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns(new OidcTokenResult
{
IdentityToken = TestOidcToken,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Subject = "test@test.com",
Email = "test@test.com"
});
_fulcioClient.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns(new FulcioCertificateResult(
Certificate: _testCertificate,
CertificateChain: _testCertChain,
SignedCertificateTimestamp: "test-sct",
NotBefore: DateTimeOffset.UtcNow.AddMinutes(-1),
NotAfter: DateTimeOffset.UtcNow.AddMinutes(10),
Identity: new FulcioIdentity(
Issuer: "https://test.auth",
Subject: "test@test.com",
SubjectAlternativeName: "test@test.com")));
_signer = new KeylessDsseSigner(
_keyGenerator,
_fulcioClient,
_tokenProvider,
_options,
_logger);
}
public void Dispose()
{
_signer.Dispose();
}
[Fact]
public async Task SignAsync_ValidRequest_ReturnsSigningBundle()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.Should().NotBeNull();
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
bundle.Envelope.Signatures.Should().HaveCount(1);
bundle.Metadata.Should().NotBeNull();
}
[Fact]
public async Task SignAsync_AcquiresOidcToken()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await _tokenProvider.Received(1).AcquireTokenAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task SignAsync_RequestsFulcioCertificate()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await _fulcioClient.Received(1).GetCertificateAsync(
Arg.Is<FulcioCertificateRequest>(r =>
r.OidcIdentityToken == TestOidcToken &&
r.Algorithm == KeylessAlgorithms.EcdsaP256),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task SignAsync_IncludesCertificateChainInMetadata()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.CertificateChain.Should().NotBeNullOrEmpty();
bundle.Metadata.CertificateChain.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public async Task SignAsync_IncludesIdentityInMetadata()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.Identity.Should().NotBeNull();
bundle.Metadata.Identity.Issuer.Should().Be("https://test.auth");
bundle.Metadata.Identity.Subject.Should().Be("test@test.com");
bundle.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignAsync_OidcTokenAcquisitionFails_ThrowsException()
{
// Arrange
_tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns<OidcTokenResult>(_ => throw new OidcTokenAcquisitionException(
"https://test.auth", "Token acquisition failed"));
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var act = async () => await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<OidcTokenAcquisitionException>();
}
[Fact]
public async Task SignAsync_FulcioUnavailable_ThrowsException()
{
// Arrange
_fulcioClient.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns<FulcioCertificateResult>(_ => throw new FulcioUnavailableException(
"https://fulcio.test", "Service unavailable"));
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var act = async () => await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>();
}
[Fact]
public async Task SignAsync_NullRequest_ThrowsArgumentNullException()
{
// Arrange
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var act = async () => await _signer.SignAsync(null!, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SignAsync_NullEntitlement_ThrowsArgumentNullException()
{
// Arrange
var request = CreateTestSigningRequest();
var caller = CreateTestCallerContext();
// Act
var act = async () => await _signer.SignAsync(request, null!, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SignAsync_NullCaller_ThrowsArgumentNullException()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
// Act
var act = async () => await _signer.SignAsync(request, entitlement, null!, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public void Algorithm_ReturnsPreferredAlgorithm()
{
// Assert
_signer.Algorithm.Should().Be(KeylessAlgorithms.EcdsaP256);
}
[Fact]
public async Task SignAsync_AfterDispose_ThrowsObjectDisposedException()
{
// Arrange
_signer.Dispose();
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var act = async () => await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ObjectDisposedException>();
}
[Fact]
public async Task SignAsync_MultipleSubjects_IncludesAllInStatement()
{
// Arrange
var subjects = new List<SigningSubject>
{
new("artifact1", new Dictionary<string, string> { ["sha256"] = "abc123" }),
new("artifact2", new Dictionary<string, string> { ["sha256"] = "def456" })
};
var predicate = JsonDocument.Parse("""{"verdict": "pass"}""");
var request = new SigningRequest(
Subjects: subjects,
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:test",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test-token"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
// The payload should contain both subjects
var payloadJson = Convert.FromBase64String(bundle.Envelope.Payload);
var payload = JsonDocument.Parse(payloadJson);
payload.RootElement.GetProperty("subject").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task SignAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
var request = CreateTestSigningRequest();
var entitlement = CreateTestEntitlement();
var caller = CreateTestCallerContext();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Configure mock to respect cancellation
_tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns<OidcTokenResult>(_ => throw new OperationCanceledException());
// Act
var act = async () => await _signer.SignAsync(request, entitlement, caller, cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
// Helper methods
private static SigningRequest CreateTestSigningRequest()
{
var predicate = JsonDocument.Parse("""
{
"verdict": "pass",
"gates": [
{"name": "drift-gate", "result": "pass"}
]
}
""");
return new SigningRequest(
Subjects:
[
new SigningSubject("test-artifact", new Dictionary<string, string>
{
["sha256"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
})
],
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:abc123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test-poe"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
}
private static ProofOfEntitlementResult CreateTestEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: "test-license",
CustomerId: "test-customer",
Plan: "enterprise",
MaxArtifactBytes: 1000000,
QpsLimit: 100,
QpsRemaining: 50,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddDays(30));
}
private static CallerContext CreateTestCallerContext()
{
return new CallerContext(
Subject: "test@test.com",
Tenant: "test-tenant",
Scopes: ["signer:sign"],
Audiences: ["signer"],
SenderBinding: null,
ClientCertificateThumbprint: null);
}
private static byte[] GenerateTestCertificate()
{
// Generate a minimal self-signed certificate for testing
using var rsa = System.Security.Cryptography.RSA.Create(2048);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=Test Certificate",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(10));
return cert.RawData;
}
}

View File

@@ -0,0 +1,610 @@
// -----------------------------------------------------------------------------
// KeylessSigningIntegrationTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Tasks: 0017, 0018 - Integration tests for full keyless signing flow
// Description: End-to-end integration tests with mock Fulcio server
// -----------------------------------------------------------------------------
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.Core;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
/// <summary>
/// Integration tests for the full keyless signing flow.
/// Validates the complete pipeline: OIDC token -> Fulcio cert -> DSSE signing.
/// </summary>
public sealed class KeylessSigningIntegrationTests : IDisposable
{
private readonly MockFulcioServer _mockFulcio;
private readonly SignerKeylessOptions _options;
private readonly List<IDisposable> _disposables = [];
public KeylessSigningIntegrationTests()
{
_mockFulcio = new MockFulcioServer();
_options = new SignerKeylessOptions
{
Enabled = true,
Fulcio = new FulcioOptions
{
Url = "https://fulcio.test",
Timeout = TimeSpan.FromSeconds(30),
Retries = 3,
BackoffBase = TimeSpan.FromMilliseconds(10),
BackoffMax = TimeSpan.FromMilliseconds(100)
},
Algorithms = new AlgorithmOptions
{
Preferred = KeylessAlgorithms.EcdsaP256,
Allowed = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519]
},
Certificate = new CertificateOptions
{
ValidateChain = false, // Disable for tests with self-signed certs
RequireSct = false
},
Identity = new IdentityOptions
{
ExpectedIssuers = [],
ExpectedSubjectPatterns = []
}
};
}
public void Dispose()
{
foreach (var d in _disposables)
d.Dispose();
_mockFulcio.Dispose();
}
[Fact]
public async Task FullKeylessFlow_ValidOidcToken_ProducesDsseBundle()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// 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();
}
[Fact]
public async Task FullKeylessFlow_ProducesValidInTotoStatement()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - decode and validate the in-toto statement
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
var statement = JsonDocument.Parse(payloadBytes);
statement.RootElement.GetProperty("_type").GetString()
.Should().Be("https://in-toto.io/Statement/v1");
statement.RootElement.GetProperty("subject").GetArrayLength()
.Should().BeGreaterThan(0);
}
[Fact]
public async Task FullKeylessFlow_IncludesCertificateChain()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.CertificateChain.Should().NotBeNullOrEmpty();
bundle.Metadata.CertificateChain.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public async Task FullKeylessFlow_IncludesSigningIdentity()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("ci@github.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.Identity.Should().NotBeNull();
bundle.Metadata.Identity.Mode.Should().Be("keyless");
bundle.Metadata.Identity.Subject.Should().Be("ci@github.com");
}
[Fact]
public async Task FullKeylessFlow_EachSigningProducesDifferentSignature()
{
// Arrange - ephemeral keys mean different signatures each time
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle1 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
var bundle2 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - different ephemeral keys = different signatures
bundle1.Envelope.Signatures[0].Signature.Should()
.NotBe(bundle2.Envelope.Signatures[0].Signature,
"each signing should use a new ephemeral key");
}
[Fact]
public async Task FullKeylessFlow_FulcioUnavailable_ThrowsException()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = Substitute.For<IFulcioClient>();
fulcioClient.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns<FulcioCertificateResult>(_ => throw new FulcioUnavailableException(
"https://fulcio.test", "Service unavailable"));
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>();
}
[Fact]
public async Task FullKeylessFlow_OidcTokenInvalid_ThrowsException()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = Substitute.For<IOidcTokenProvider>();
tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns<OidcTokenResult>(_ => throw new OidcTokenAcquisitionException(
"https://auth.test", "Token expired"));
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<OidcTokenAcquisitionException>();
}
[Fact]
public async Task SignedBundle_CanBeVerified_WithEmbeddedCertificate()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - the bundle should contain all data needed for verification
bundle.Should().NotBeNull();
bundle.Metadata.CertificateChain.Should().NotBeEmpty(
"bundle must include certificate chain for verification");
bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty(
"bundle must include signature");
bundle.Envelope.Payload.Should().NotBeNullOrEmpty(
"bundle must include payload for verification");
// Verify the certificate chain can be parsed
var leafCertBase64 = bundle.Metadata.CertificateChain.First();
var act = () =>
{
var pemContent = Encoding.UTF8.GetString(Convert.FromBase64String(leafCertBase64));
return true;
};
act.Should().NotThrow("certificate should be valid base64");
}
[Fact]
public async Task MultipleSubjects_AllIncludedInStatement()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
// Create request with multiple subjects
var subjects = new List<SigningSubject>
{
new("artifact-1", new Dictionary<string, string> { ["sha256"] = "abc123" }),
new("artifact-2", new Dictionary<string, string> { ["sha256"] = "def456" }),
new("artifact-3", new Dictionary<string, string> { ["sha256"] = "ghi789" })
};
var predicate = JsonDocument.Parse("{\"verdict\": \"pass\"}");
var request = new SigningRequest(
Subjects: subjects,
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:test",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
var statement = JsonDocument.Parse(payloadBytes);
statement.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
// Helper methods
private IFulcioClient CreateMockFulcioClient()
{
var client = Substitute.For<IFulcioClient>();
client.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var request = callInfo.Arg<FulcioCertificateRequest>();
return _mockFulcio.IssueCertificate(request);
});
return client;
}
private static IOidcTokenProvider CreateMockTokenProvider(string subject)
{
var provider = Substitute.For<IOidcTokenProvider>();
var issuer = "https://test.auth";
provider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns(new OidcTokenResult
{
IdentityToken = CreateOidcToken(subject, issuer),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Subject = subject,
Email = subject
});
return provider;
}
private static string CreateOidcToken(string subject, string issuer)
{
var header = Base64UrlEncode("{\"typ\":\"JWT\",\"alg\":\"RS256\"}");
var payload = JsonSerializer.Serialize(new
{
iss = issuer,
sub = subject,
email = subject,
exp = 9999999999L
});
var payloadEncoded = Base64UrlEncode(payload);
return $"{header}.{payloadEncoded}.sig";
}
private static string Base64UrlEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
var remainder = padded.Length % 4;
if (remainder == 2)
{
padded += "==";
}
else if (remainder == 3)
{
padded += "=";
}
return Convert.FromBase64String(padded);
}
private static SigningRequest CreateSigningRequest()
{
var predicate = JsonDocument.Parse("""
{
"verdict": "pass",
"gates": [{"name": "drift", "result": "pass"}]
}
""");
return new SigningRequest(
Subjects:
[
new SigningSubject("test-artifact", new Dictionary<string, string>
{
["sha256"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
})
],
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:abc123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test-poe"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
}
private static ProofOfEntitlementResult CreateEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: "test-license",
CustomerId: "test-customer",
Plan: "enterprise",
MaxArtifactBytes: 1000000,
QpsLimit: 100,
QpsRemaining: 50,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddDays(30));
}
private static CallerContext CreateCallerContext()
{
return new CallerContext(
Subject: "test@test.com",
Tenant: "test-tenant",
Scopes: ["signer:sign"],
Audiences: ["signer"],
SenderBinding: null,
ClientCertificateThumbprint: null);
}
/// <summary>
/// Mock Fulcio server for integration testing.
/// </summary>
private sealed class MockFulcioServer : IDisposable
{
private readonly X509Certificate2 _rootCa;
private readonly RSA _rootKey;
public MockFulcioServer()
{
_rootKey = RSA.Create(2048);
var request = new CertificateRequest(
"CN=Mock Fulcio Root CA, O=Test",
_rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(true, false, 0, true));
_rootCa = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(10));
}
public FulcioCertificateResult IssueCertificate(FulcioCertificateRequest request)
{
var (issuer, subject) = TryParseOidcIdentity(request.OidcIdentityToken);
var resolvedIssuer = string.IsNullOrWhiteSpace(issuer) ? "https://test.auth" : issuer;
var resolvedSubject = string.IsNullOrWhiteSpace(subject) ? "test@test.com" : subject;
// Create a leaf certificate signed by our mock CA
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
$"CN={resolvedSubject}, O=Test",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add Fulcio OIDC issuer extension
var issuerOid = new Oid("1.3.6.1.4.1.57264.1.1");
var issuerBytes = Encoding.UTF8.GetBytes(resolvedIssuer);
leafRequest.CertificateExtensions.Add(new X509Extension(issuerOid, issuerBytes, false));
var serial = new byte[16];
RandomNumberGenerator.Fill(serial);
var leafCert = leafRequest.Create(
_rootCa,
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddMinutes(10),
serial);
var notBefore = new DateTimeOffset(leafCert.NotBefore.ToUniversalTime());
var notAfter = new DateTimeOffset(leafCert.NotAfter.ToUniversalTime());
return new FulcioCertificateResult(
Certificate: leafCert.RawData,
CertificateChain: [_rootCa.RawData],
SignedCertificateTimestamp: "mock-sct",
NotBefore: notBefore,
NotAfter: notAfter,
Identity: new FulcioIdentity(
Issuer: resolvedIssuer,
Subject: resolvedSubject,
SubjectAlternativeName: resolvedSubject));
}
private static (string? Issuer, string? Subject) TryParseOidcIdentity(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return (null, null);
}
var parts = token.Split('.');
if (parts.Length < 2)
{
return (null, null);
}
try
{
var payloadBytes = Base64UrlDecode(parts[1]);
using var doc = JsonDocument.Parse(payloadBytes);
var root = doc.RootElement;
string? issuer = null;
if (root.TryGetProperty("iss", out var issProp))
{
issuer = issProp.GetString();
}
string? subject = null;
if (root.TryGetProperty("email", out var emailProp))
{
subject = emailProp.GetString();
}
if (string.IsNullOrWhiteSpace(subject) && root.TryGetProperty("sub", out var subProp))
{
subject = subProp.GetString();
}
return (issuer, subject);
}
catch (JsonException)
{
return (null, null);
}
catch (FormatException)
{
return (null, null);
}
}
public void Dispose()
{
_rootCa.Dispose();
_rootKey.Dispose();
}
}
}

View File

@@ -0,0 +1,751 @@
// -----------------------------------------------------------------------------
// SignerNegativeTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-014 - Add negative tests: unsupported algorithms, malformed payloads, oversized inputs
// Description: Comprehensive negative tests for Signer WebService
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Negative;
/// <summary>
/// Negative tests for Signer WebService.
/// Validates:
/// - Unsupported algorithm rejection with clear error codes
/// - Malformed payload handling with deterministic errors
/// - Oversized input rejection with appropriate limits
/// - Invalid request structure handling
/// </summary>
[Trait("Category", "Negative")]
[Trait("Category", "ErrorHandling")]
[Trait("Category", "W1")]
public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ITestOutputHelper _output;
// Size limits for testing
private const int MaxPayloadSizeBytes = 10 * 1024 * 1024; // 10 MB
private const int MaxSubjectCount = 1000;
public SignerNegativeTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
#region Unsupported Algorithm Tests
[Theory]
[InlineData("MD5")]
[InlineData("SHA1")]
[InlineData("DSA")]
[InlineData("RSA-PKCS1")]
[InlineData("unknown-algorithm")]
[InlineData("FOOBAR256")]
public async Task SignDsse_UnsupportedSigningMode_Returns400WithErrorCode(string algorithm)
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[] { CreateValidSubject() },
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
poe = CreateValidPoe(),
options = new { signingMode = algorithm }
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Algorithm '{algorithm}': {response.StatusCode}");
_output.WriteLine($"Response: {content}");
content.Should().Contain("signing_mode_invalid", "error message should reference the signing mode");
}
[Fact]
public async Task SignDsse_NullAlgorithm_UsesDefault()
{
// Arrange - when algorithm is not specified, should use default
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[] { CreateValidSubject() },
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
// No algorithm specified - should use default
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - should not fail due to missing algorithm (400 is ok for other reasons)
_output.WriteLine($"No algorithm specified: {response.StatusCode}");
// If we get 400, it should NOT be about the algorithm
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var content = await response.Content.ReadAsStringAsync();
content.Should().NotContain("unsupported algorithm",
"missing algorithm should use default, not fail");
}
}
#endregion
#region Malformed Payload Tests
[Fact]
public async Task SignDsse_EmptyBody_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("", Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
_output.WriteLine($"Empty body: {response.StatusCode}");
}
[Fact]
public async Task SignDsse_InvalidJson_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("{invalid json", Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Invalid JSON: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
}
[Fact]
public async Task SignDsse_MissingSubject_Returns400WithFieldError()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
// Missing 'subject' field
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Missing subject: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
content.ToLower().Should().Contain("subject", "error should mention missing subject field");
}
[Fact]
public async Task SignDsse_EmptySubjectArray_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = Array.Empty<object>(), // Empty array
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Empty subject array: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
}
[Fact]
public async Task SignDsse_SubjectMissingName_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
// Missing 'name'
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Subject missing name: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
}
[Fact]
public async Task SignDsse_SubjectMissingDigest_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0"
// Missing 'digest'
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Subject missing digest: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
}
[Theory]
[InlineData("not-a-valid-purl")]
[InlineData("http://example.com/not-a-purl")]
[InlineData("pkg:")]
[InlineData("pkg:invalid")]
public async Task SignDsse_InvalidPurl_Returns400(string invalidPurl)
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = invalidPurl,
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - may or may not validate PURL format
_output.WriteLine($"Invalid PURL '{invalidPurl}': {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Response: {content}");
}
}
[Theory]
[InlineData("md5", "d41d8cd98f00b204e9800998ecf8427e")] // MD5 is insecure
[InlineData("sha1", "da39a3ee5e6b4b0d3255bfef95601890afd80709")] // SHA1 is deprecated
public async Task SignDsse_InsecureDigestAlgorithm_Returns400(string algorithm, string hash)
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string> { [algorithm] = hash }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
_output.WriteLine($"Insecure digest algorithm '{algorithm}': {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Response: {content}");
content.ToLower().Should().ContainAny(
"algorithm", "digest", "insecure", "deprecated", "sha256");
}
}
[Fact]
public async Task SignDsse_MissingPredicateType_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[] { CreateValidSubject() },
// Missing predicateType
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Missing predicateType: {response.StatusCode}");
_output.WriteLine($"Response: {content}");
}
#endregion
#region Oversized Input Tests
[Fact]
public async Task SignDsse_OversizedPayload_Returns413OrRejects()
{
// Arrange - Create a large payload that exceeds reasonable limits
var largePayload = new string('x', MaxPayloadSizeBytes + 1);
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[] { CreateValidSubject() },
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { data = largePayload },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - should be either 413 (Payload Too Large) or 400 (Bad Request)
response.StatusCode.Should().BeOneOf(
HttpStatusCode.RequestEntityTooLarge,
HttpStatusCode.BadRequest);
_output.WriteLine($"Oversized payload (~{MaxPayloadSizeBytes / 1024 / 1024}+ MB): {response.StatusCode}");
}
[Fact]
public async Task SignDsse_TooManySubjects_Returns400()
{
// Arrange - Create request with many subjects
var subjects = Enumerable.Range(0, MaxSubjectCount + 1)
.Select(i => new
{
name = $"pkg:npm/example-{i}@1.0.0",
digest = new Dictionary<string, string> { ["sha256"] = $"{i:x64}" }
})
.ToArray();
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = subjects,
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - should be rejected or limited
_output.WriteLine($"Too many subjects ({MaxSubjectCount + 1}): {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Response: {content.Substring(0, Math.Min(500, content.Length))}...");
}
}
[Fact]
public async Task SignDsse_VeryLongSubjectName_Returns400()
{
// Arrange
var longName = "pkg:npm/" + new string('a', 65536) + "@1.0.0"; // 64KB name
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = longName,
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
poe = CreateValidPoe()
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.RequestEntityTooLarge);
_output.WriteLine($"Very long subject name (64KB): {response.StatusCode}");
}
[Fact]
public async Task SignDsse_DeeplyNestedPredicate_HandledGracefully()
{
// Arrange - Create deeply nested JSON
var nested = BuildNestedObject(100);
var client = _factory.CreateClient();
var jsonOptions = new JsonSerializerOptions { MaxDepth = 256 };
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[] { CreateValidSubject() },
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = nested,
poe = CreateValidPoe()
}, options: jsonOptions)
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert - should be handled (either accepted or rejected gracefully)
_output.WriteLine($"Deeply nested predicate (100 levels): {response.StatusCode}");
// Should not be 500 (server error)
response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError);
}
#endregion
#region Invalid Request Structure Tests
[Fact]
public async Task SignDsse_WrongContentType_Returns415()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("{}", Encoding.UTF8, "text/plain")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert - should be 415 (Unsupported Media Type) or 400
response.StatusCode.Should().BeOneOf(
HttpStatusCode.UnsupportedMediaType,
HttpStatusCode.BadRequest);
_output.WriteLine($"Wrong content type (text/plain): {response.StatusCode}");
}
[Fact]
public async Task SignDsse_XmlPayload_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("<request><subject/></request>", Encoding.UTF8, "application/xml")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.UnsupportedMediaType,
HttpStatusCode.BadRequest);
_output.WriteLine($"XML payload: {response.StatusCode}");
}
[Fact]
public async Task SignDsse_NullBody_Returns400()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("null", Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
_output.WriteLine($"Null JSON body: {response.StatusCode}");
}
[Fact]
public async Task SignDsse_ArrayBody_Returns400()
{
// Arrange - JSON array instead of object
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = new StringContent("[1,2,3]", Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
_output.WriteLine($"Array JSON body: {response.StatusCode}");
}
#endregion
#region Error Response Format Tests
[Fact]
public async Task SignDsse_Error_ReturnsStructuredErrorResponse()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new { invalid = "request" })
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Error response: {content}");
// Error response should be valid JSON
Action parseJson = () => JsonDocument.Parse(content);
parseJson.Should().NotThrow("error response should be valid JSON");
// Should have consistent structure
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Check for common error response fields
var hasErrorField = root.TryGetProperty("error", out _) ||
root.TryGetProperty("errors", out _) ||
root.TryGetProperty("title", out _) ||
root.TryGetProperty("message", out _);
hasErrorField.Should().BeTrue("error response should have error information");
}
[Fact]
public async Task SignDsse_Error_ResponseIncludesRequestId()
{
// Arrange
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new { invalid = "request" })
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("X-Request-ID", "test-request-123");
// Act
var response = await client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
// Check for request ID in response
if (response.Headers.TryGetValues("X-Request-ID", out var requestIds))
{
_output.WriteLine($"Request ID in response: {string.Join(", ", requestIds)}");
requestIds.Should().Contain("test-request-123");
}
else
{
_output.WriteLine(" X-Request-ID not echoed in response headers");
}
}
#endregion
#region Helper Methods
private static object CreateValidSubject()
{
return new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string>
{
["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
}
};
}
private static object BuildNestedObject(int depth)
{
if (depth <= 0)
{
return "leaf";
}
return new Dictionary<string, object>
{
["level"] = depth,
["nested"] = BuildNestedObject(depth - 1)
};
}
private static object CreateValidPoe()
{
return new
{
format = "jwt",
value = "valid-poe"
};
}
#endregion
}

View File

@@ -0,0 +1,389 @@
// -----------------------------------------------------------------------------
// SignerOTelTraceTests.cs
// Sprint: SPRINT_5100_0009_0006 - Signer Module Test Implementation
// Task: SIGNER-5100-013 - Add OTel trace assertions (verify key_id, algorithm, signature_id tags)
// Description: OpenTelemetry trace assertion tests for Signer WebService
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Signer.Tests.Observability;
/// <summary>
/// OpenTelemetry trace assertion tests for Signer WebService.
/// Validates:
/// - Traces are created for signing operations
/// - Traces include key_id, algorithm, signature_id attributes
/// - Error spans record exception details
/// - Semantic conventions are followed
/// </summary>
[Trait("Category", "OTel")]
[Trait("Category", "Observability")]
[Trait("Category", "W1")]
public sealed class SignerOTelTraceTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ITestOutputHelper _output;
public SignerOTelTraceTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
#region Trace Creation Tests
[Fact]
public async Task SignDsse_CreatesRequestTrace()
{
// Arrange
var collectedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains("Signer") ||
source.Name.Contains("StellaOps") ||
source.Name.Contains("Microsoft.AspNetCore"),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity => collectedActivities.Add(activity)
};
ActivitySource.AddActivityListener(listener);
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
// Assert
// Allow some time for traces to be recorded
await Task.Delay(100);
_output.WriteLine($"Response status: {response.StatusCode}");
_output.WriteLine($"Activities collected: {collectedActivities.Count}");
foreach (var activity in collectedActivities)
{
_output.WriteLine($" - {activity.DisplayName} ({activity.Source.Name})");
foreach (var tag in activity.Tags)
{
_output.WriteLine($" {tag.Key}: {tag.Value}");
}
}
// At minimum, we should have HTTP request activity
collectedActivities.Should().NotBeEmpty("request should create at least one activity");
_output.WriteLine("✓ Request creates trace activities");
}
#endregion
#region Signer-Specific Attribute Tests
[Fact]
public async Task SignDsse_TraceMayIncludeKeyId()
{
// Arrange
var collectedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains("Signer") || source.Name.Contains("Crypto"),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity => collectedActivities.Add(activity)
};
ActivitySource.AddActivityListener(listener);
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var response = await client.SendAsync(request);
await Task.Delay(100);
// Assert - look for signing-related attributes
var signingActivities = collectedActivities
.Where(a => a.Tags.Any(t =>
t.Key.Contains("key") ||
t.Key.Contains("algorithm") ||
t.Key.Contains("signer")))
.ToList();
if (signingActivities.Any())
{
foreach (var activity in signingActivities)
{
_output.WriteLine($"Signing activity: {activity.DisplayName}");
foreach (var tag in activity.Tags)
{
_output.WriteLine($" {tag.Key}: {tag.Value}");
}
}
_output.WriteLine("✓ Signing trace includes key/algorithm attributes");
}
else
{
_output.WriteLine(" No signing-specific activities captured (may be internal)");
}
}
[Fact]
public async Task SignDsse_ExpectedAttributes()
{
// Document the expected attributes that SHOULD be present
// These are the semantic conventions for signing operations
var expectedAttributes = new[]
{
"signer.key_id",
"signer.algorithm",
"signer.signature_id",
"signer.subject_count",
"signer.predicate_type",
"signer.signing_mode"
};
_output.WriteLine("=== Expected Signer Trace Attributes (Semantic Conventions) ===");
foreach (var attr in expectedAttributes)
{
_output.WriteLine($" - {attr}");
}
_output.WriteLine("");
_output.WriteLine("Standard HTTP attributes:");
_output.WriteLine(" - http.method");
_output.WriteLine(" - http.url");
_output.WriteLine(" - http.status_code");
_output.WriteLine(" - http.request_content_length");
_output.WriteLine("");
_output.WriteLine("Error attributes (on failure):");
_output.WriteLine(" - exception.type");
_output.WriteLine(" - exception.message");
_output.WriteLine(" - otel.status_code = ERROR");
expectedAttributes.Should().NotBeEmpty();
}
#endregion
#region Error Trace Tests
[Fact]
public async Task SignDsse_Error_RecordsExceptionInTrace()
{
// Arrange
var collectedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity => collectedActivities.Add(activity),
ActivityStopped = activity =>
{
// Capture on stop to ensure all tags are present
}
};
ActivitySource.AddActivityListener(listener);
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new { invalid = "request" })
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
// Act
var response = await client.SendAsync(request);
await Task.Delay(100);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
// Look for error indicators in activities
var errorActivities = collectedActivities
.Where(a =>
a.Status == ActivityStatusCode.Error ||
a.Tags.Any(t => t.Key.Contains("error") || t.Key.Contains("exception")))
.ToList();
_output.WriteLine($"Error activities found: {errorActivities.Count}");
foreach (var activity in errorActivities)
{
_output.WriteLine($" {activity.DisplayName}: Status={activity.Status}");
foreach (var ev in activity.Events)
{
_output.WriteLine($" Event: {ev.Name}");
}
}
// At minimum, HTTP activity should record the error status code
var httpActivities = collectedActivities
.Where(a => a.Tags.Any(t => t.Key == "http.status_code"))
.ToList();
if (httpActivities.Any())
{
var statusCodeTag = httpActivities.First().Tags
.FirstOrDefault(t => t.Key == "http.status_code");
_output.WriteLine($"✓ HTTP status code recorded: {statusCodeTag.Value}");
}
}
#endregion
#region Trace Correlation Tests
[Fact]
public async Task SignDsse_PreservesTraceContext()
{
// Arrange
var parentTraceId = ActivityTraceId.CreateRandom().ToString();
var parentSpanId = ActivitySpanId.CreateRandom().ToString();
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
request.Headers.Add("traceparent", $"00-{parentTraceId}-{parentSpanId}-01");
// Act
var response = await client.SendAsync(request);
// Assert
// The response should ideally preserve the trace context
_output.WriteLine($"Parent trace ID: {parentTraceId}");
_output.WriteLine($"Parent span ID: {parentSpanId}");
_output.WriteLine($"Response status: {response.StatusCode}");
// Check if traceresponse header is present
if (response.Headers.TryGetValues("traceresponse", out var traceResponse))
{
_output.WriteLine($"Trace response: {string.Join(", ", traceResponse)}");
_output.WriteLine("✓ Trace context is propagated");
}
else
{
_output.WriteLine(" No traceresponse header (may not be configured)");
}
}
#endregion
#region Performance Attribute Tests
[Fact]
public async Task SignDsse_IncludesDurationMetrics()
{
// Arrange
var collectedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity => collectedActivities.Add(activity)
};
ActivitySource.AddActivityListener(listener);
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(CreateBasicSignRequest())
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.SendAsync(request);
stopwatch.Stop();
await Task.Delay(100);
// Assert
_output.WriteLine($"Request duration: {stopwatch.ElapsedMilliseconds}ms");
_output.WriteLine($"Activities with duration:");
foreach (var activity in collectedActivities.Where(a => a.Duration > TimeSpan.Zero))
{
_output.WriteLine($" {activity.DisplayName}: {activity.Duration.TotalMilliseconds:F2}ms");
}
// Activities should have non-zero duration
collectedActivities.Where(a => a.Duration > TimeSpan.Zero)
.Should().NotBeEmpty("activities should track duration");
_output.WriteLine("✓ Duration metrics recorded");
}
#endregion
#region Attribute Summary
[Fact]
public void AttributeDocumentation_SummarizesExpectedTags()
{
_output.WriteLine("=== Signer OTel Attribute Reference ===");
_output.WriteLine("");
_output.WriteLine("Signing Operation Attributes:");
_output.WriteLine(" signer.key_id - Key identifier used for signing");
_output.WriteLine(" signer.algorithm - Signing algorithm (ES256, Ed25519, etc.)");
_output.WriteLine(" signer.signature_id - Unique identifier for the signature");
_output.WriteLine(" signer.bundle_type - Type of bundle returned (dsse, dsse+cert)");
_output.WriteLine(" signer.subject_count - Number of subjects in the statement");
_output.WriteLine(" signer.predicate_type - Predicate type URL");
_output.WriteLine("");
_output.WriteLine("Security Attributes:");
_output.WriteLine(" auth.method - Authentication method used");
_output.WriteLine(" auth.has_dpop - Whether DPoP proof was provided");
_output.WriteLine(" poe.format - Proof of execution format");
_output.WriteLine("");
_output.WriteLine("Performance Attributes:");
_output.WriteLine(" signer.canonicalization_ms - Time spent canonicalizing payload");
_output.WriteLine(" signer.signing_ms - Time spent on crypto operation");
_output.WriteLine(" signer.bundle_assembly_ms - Time spent assembling bundle");
}
#endregion
#region Helper Methods
private static object CreateBasicSignRequest()
{
return new
{
subject = new[]
{
new
{
name = "pkg:npm/example@1.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e" }
}
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" }
};
}
#endregion
}

View File

@@ -0,0 +1,316 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Signer.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Signer.Tests;
public sealed class SignerEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private const string TrustedDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
public SignerEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignDsse_ReturnsBundle_WhenRequestValid()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = TrustedDigest,
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<SignDsseResponseDto>();
Assert.NotNull(body);
Assert.Equal("stub-subject", body!.Bundle.SigningIdentity.Subject);
Assert.Equal("stub-subject", body.Bundle.SigningIdentity.Issuer);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignDsse_ReturnsForbidden_WhenDigestUntrusted()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var problemJson = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var problem = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(problemJson, new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.NotNull(problem);
Assert.Equal("release_untrusted", problem!.Type);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyReferrers_ReturnsTrustedResult_WhenDigestIsKnown()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/signer/verify/referrers?digest={TrustedDigest}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<VerifyReferrersResponseDto>();
Assert.NotNull(body);
Assert.True(body!.Trusted);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyReferrers_ReturnsProblem_WhenDigestMissing()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/signer/verify/referrers");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyDsse_ReturnsVerifiedTrue_ForFreshSignature()
{
var client = CreateClient();
var signRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = TrustedDigest,
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
signRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
signRequest.Headers.Add("DPoP", "stub-proof");
var signResponse = await client.SendAsync(signRequest);
Assert.True(signResponse.IsSuccessStatusCode);
var signed = await signResponse.Content.ReadFromJsonAsync<SignDsseResponseDto>();
Assert.NotNull(signed);
var verifyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/verify/dsse")
{
Content = JsonContent.Create(new { bundle = new { dsse = signed!.Bundle.Dsse } })
};
verifyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var verifyResponse = await client.SendAsync(verifyRequest);
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
Assert.True(verifyResponse.IsSuccessStatusCode, $"Expected verification success but got {(int)verifyResponse.StatusCode}: {verifyBody}");
var verification = await verifyResponse.Content.ReadFromJsonAsync<VerifyDsseResponseDto>();
Assert.NotNull(verification);
Assert.True(verification!.Verified);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyDsse_ReturnsVerifiedFalse_WhenPayloadIsTampered()
{
var client = CreateClient();
var signRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = TrustedDigest,
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
signRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
signRequest.Headers.Add("DPoP", "stub-proof");
var signResponse = await client.SendAsync(signRequest);
Assert.True(signResponse.IsSuccessStatusCode);
var signed = await signResponse.Content.ReadFromJsonAsync<SignDsseResponseDto>();
Assert.NotNull(signed);
var payloadBytes = Convert.FromBase64String(signed!.Bundle.Dsse.Payload);
payloadBytes[0] ^= 0x01;
var tamperedPayload = Convert.ToBase64String(payloadBytes);
var verifyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/verify/dsse")
{
Content = JsonContent.Create(new
{
dsse = new
{
payloadType = signed.Bundle.Dsse.PayloadType,
payload = tamperedPayload,
signatures = signed.Bundle.Dsse.Signatures
}
})
};
verifyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var verifyResponse = await client.SendAsync(verifyRequest);
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
var verification = await verifyResponse.Content.ReadFromJsonAsync<VerifyDsseResponseDto>();
Assert.NotNull(verification);
Assert.False(verification!.Verified);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CeremoniesRoute_IsMapped_AndProtectedByAuthorization()
{
var client = CreateClient();
var response = await client.GetAsync("/api/v1/ceremonies/");
Assert.True(
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
$"Expected unauthorized/forbidden for mapped ceremonies route, got {(int)response.StatusCode}.");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Ceremonies_CreateAndGet_WorksForAuthenticatedCaller()
{
var client = CreateClient();
var createRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ceremonies/")
{
Content = JsonContent.Create(new
{
operationType = "keygeneration",
thresholdRequired = 2,
description = "endpoint test"
})
};
createRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var createResponse = await client.SendAsync(createRequest);
var createBody = await createResponse.Content.ReadAsStringAsync();
Assert.True(createResponse.IsSuccessStatusCode, $"Expected ceremony creation success but got {(int)createResponse.StatusCode}: {createBody}");
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
using var doc = System.Text.Json.JsonDocument.Parse(createBody);
var ceremonyId = doc.RootElement.GetProperty("ceremonyId").GetGuid();
var getRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/ceremonies/{ceremonyId}");
getRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var getResponse = await client.SendAsync(getRequest);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Ceremonies_Create_ReturnsBadRequest_ForUnknownOperationType()
{
var client = CreateClient();
var createRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ceremonies/")
{
Content = JsonContent.Create(new
{
operationType = "not_real",
thresholdRequired = 1,
})
};
createRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var createResponse = await client.SendAsync(createRequest);
var responseBody = await createResponse.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
Assert.Contains("Unknown operation type", responseBody);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task KeyValidity_ReturnsNotFound_ForUnknownAnchorOrKey()
{
var client = CreateClient();
var request = new HttpRequestMessage(
HttpMethod.Get,
"/api/v1/anchors/11111111-1111-1111-1111-111111111111/keys/missing-key/validity");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private HttpClient CreateClient() => _factory.CreateClient();
}

View File

@@ -0,0 +1,427 @@
// -----------------------------------------------------------------------------
// CanonicalPayloadDeterminismTests.cs
// Sprint: SPRINT_5100_0009_0006_signer_tests
// Tasks: SIGNER-5100-001, SIGNER-5100-002, SIGNER-5100-003
// Description: Model L0 tests for canonical payload and digest determinism
// -----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Signer.Core;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
/// <summary>
/// Tests for canonical payload bytes and deterministic hash computation.
/// Implements Model L0 test requirements:
/// - SIGNER-5100-001: Canonical payload bytes snapshot tests for DSSE/in-toto envelopes
/// - SIGNER-5100-002: Stable digest computation tests: same input -> same SHA-256 hash
/// - SIGNER-5100-003: Determinism test: canonical payload hash stable across runs
/// </summary>
[Trait("Category", "Unit")]
[Trait("Category", "Determinism")]
[Trait("Category", "CanonicalPayload")]
public sealed class CanonicalPayloadDeterminismTests
{
// SIGNER-5100-001: Canonical payload bytes snapshot tests
[Fact]
public void InTotoStatement_CanonicalBytes_MatchesExpectedSnapshot()
{
// Arrange - Create a deterministic in-toto statement
var statement = CreateDeterministicInTotoStatement();
// Act
var canonicalBytes = CanonJson.Canonicalize(statement);
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
// Assert - Keys should be sorted, no whitespace
canonicalJson.Should().NotContain("\n", "canonical JSON should have no newlines");
canonicalJson.Should().NotContain(" ", "canonical JSON should have no extra spaces");
// Verify key ordering (alphabetical)
var predicateIndex = canonicalJson.IndexOf("\"predicate\"", StringComparison.Ordinal);
var predicateTypeIndex = canonicalJson.IndexOf("\"predicateType\"", StringComparison.Ordinal);
var subjectIndex = canonicalJson.IndexOf("\"subject\"", StringComparison.Ordinal);
var typeIndex = canonicalJson.IndexOf("\"_type\"", StringComparison.Ordinal);
typeIndex.Should().BeLessThan(predicateIndex, "_type should come before predicate (alphabetical)");
predicateIndex.Should().BeLessThan(predicateTypeIndex, "predicate should come before predicateType");
predicateTypeIndex.Should().BeLessThan(subjectIndex, "predicateType should come before subject");
}
[Fact]
public void InTotoStatement_DifferentKeyOrder_ProducesSameCanonicalBytes()
{
// Arrange - Create same data with different key order in source JSON
var json1 = """{"predicateType":"https://slsa.dev/provenance/v1","_type":"https://in-toto.io/Statement/v1","predicate":{"builder":{"id":"test"}},"subject":[{"name":"artifact","digest":{"sha256":"abc123"}}]}""";
var json2 = """{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"artifact","digest":{"sha256":"abc123"}}],"predicateType":"https://slsa.dev/provenance/v1","predicate":{"builder":{"id":"test"}}}""";
// Act
var bytes1 = CanonJson.CanonicalizeParsedJson(Encoding.UTF8.GetBytes(json1));
var bytes2 = CanonJson.CanonicalizeParsedJson(Encoding.UTF8.GetBytes(json2));
// Assert
bytes1.Should().BeEquivalentTo(bytes2, "canonical bytes should be identical regardless of input key order");
}
[Fact]
public void DsseEnvelope_CanonicalBytes_PayloadTypePreserved()
{
// Arrange
var envelope = CreateDeterministicDsseEnvelope();
// Act
var canonicalBytes = CanonJson.Canonicalize(envelope);
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
// Assert
canonicalJson.Should().Contain("\"payloadType\":\"application/vnd.in-toto+json\"");
}
[Fact]
public void DsseEnvelope_CanonicalBytes_SignaturesArrayPreserved()
{
// Arrange
var envelope = CreateDeterministicDsseEnvelope();
// Act
var canonicalBytes = CanonJson.Canonicalize(envelope);
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
// Assert
canonicalJson.Should().Contain("\"signatures\":[");
canonicalJson.Should().Contain("\"keyId\":\"test-key-id\"");
}
[Fact]
public void InTotoStatement_MultipleSubjects_CanonicalOrderPreserved()
{
// Arrange - Statement with multiple subjects
var statement = new
{
_type = "https://in-toto.io/Statement/v1",
predicateType = "https://slsa.dev/provenance/v1",
subject = new[]
{
new { name = "artifact-b", digest = new { sha256 = "def456" } },
new { name = "artifact-a", digest = new { sha256 = "abc123" } },
new { name = "artifact-c", digest = new { sha256 = "ghi789" } }
},
predicate = new { builder = new { id = "test-builder" } }
};
// Act
var canonicalBytes = CanonJson.Canonicalize(statement);
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
// Assert - Array order should be preserved (not sorted)
var indexB = canonicalJson.IndexOf("artifact-b", StringComparison.Ordinal);
var indexA = canonicalJson.IndexOf("artifact-a", StringComparison.Ordinal);
var indexC = canonicalJson.IndexOf("artifact-c", StringComparison.Ordinal);
indexB.Should().BeLessThan(indexA, "array order should be preserved");
indexA.Should().BeLessThan(indexC, "array order should be preserved");
}
// SIGNER-5100-002: Stable digest computation tests
[Fact]
public void Sha256Hash_SameInput_ProducesIdenticalHash()
{
// Arrange
var statement = CreateDeterministicInTotoStatement();
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2, "same input should produce same hash");
hash1.Length.Should().Be(64, "SHA-256 hash should be 64 hex characters");
}
[Fact]
public void Sha256Hash_LowercaseHex_Format()
{
// Arrange
var statement = CreateDeterministicInTotoStatement();
// Act
var hash = CanonJson.Hash(statement);
// Assert
hash.Should().MatchRegex("^[0-9a-f]{64}$", "hash should be lowercase hex");
hash.Should().NotMatchRegex("[A-F]", "hash should not contain uppercase letters");
}
[Fact]
public void Sha256Hash_Prefixed_HasCorrectPrefix()
{
// Arrange
var statement = CreateDeterministicInTotoStatement();
// Act
var prefixedHash = CanonJson.HashPrefixed(statement);
// Assert
prefixedHash.Should().StartWith("sha256:");
prefixedHash.Length.Should().Be(71, "sha256: prefix (7) + hash (64) = 71");
}
[Fact]
public void Sha256Hash_DifferentInputs_ProduceDifferentHashes()
{
// Arrange
var statement1 = new { type = "test", value = "input1" };
var statement2 = new { type = "test", value = "input2" };
// Act
var hash1 = CanonJson.Hash(statement1);
var hash2 = CanonJson.Hash(statement2);
// Assert
hash1.Should().NotBe(hash2, "different inputs should produce different hashes");
}
[Fact]
public void Sha256Hash_EmptyObject_ProducesConsistentHash()
{
// Arrange
var emptyObject = new { };
// Act
var hash1 = CanonJson.Hash(emptyObject);
var hash2 = CanonJson.Hash(emptyObject);
// Assert
hash1.Should().Be(hash2);
hash1.Should().Be("44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"empty object {} should have deterministic hash");
}
[Fact]
public void Sha256Hash_NestedObject_ProducesConsistentHash()
{
// Arrange
var nested = new
{
level1 = new
{
level2 = new
{
level3 = new
{
value = "deep"
}
}
}
};
// Act
var hash1 = CanonJson.Hash(nested);
var hash2 = CanonJson.Hash(nested);
// Assert
hash1.Should().Be(hash2, "nested objects should produce consistent hashes");
}
// SIGNER-5100-003: Determinism test - hash stable across runs
[Fact]
public void CanonicalPayload_HashStableAcrossMultipleRuns()
{
// Arrange - Create identical statements multiple times
var hashes = new HashSet<string>();
// Act - Generate hash 100 times
for (int i = 0; i < 100; i++)
{
var statement = CreateDeterministicInTotoStatement();
var hash = CanonJson.Hash(statement);
hashes.Add(hash);
}
// Assert - All hashes should be identical
hashes.Should().HaveCount(1, "all 100 runs should produce the same hash");
}
[Fact]
public void CanonicalPayload_StableWithTimestampField()
{
// Arrange - Fixed timestamp for determinism
var fixedTimestamp = DeterministicTestData.FixedTimestamp;
var statement = new
{
_type = "https://in-toto.io/Statement/v1",
timestamp = fixedTimestamp.ToString("O"),
subject = new[] { new { name = "test", digest = new { sha256 = "abc" } } }
};
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2, "fixed timestamp should produce stable hash");
}
[Fact]
public void CanonicalPayload_DeterministicWithSigningSubjects()
{
// Arrange - Use DeterministicTestData for subjects
var subjects = DeterministicTestData.CreateDefaultSubjects();
// Act
var hash1 = CanonJson.Hash(subjects);
var hash2 = CanonJson.Hash(subjects);
// Assert
hash1.Should().Be(hash2, "signing subjects should hash deterministically");
}
[Fact]
public void CanonicalPayload_DeterministicWithMultipleSubjects()
{
// Arrange
var subjects = DeterministicTestData.CreateMultipleSubjects();
// Act
var hash1 = CanonJson.Hash(subjects);
var hash2 = CanonJson.Hash(subjects);
// Assert
hash1.Should().Be(hash2, "multiple subjects should hash deterministically");
}
[Fact]
public void CanonicalPayload_UnicodeCharacters_HashDeterministically()
{
// Arrange - Statement with Unicode characters
var statement = new
{
_type = "https://in-toto.io/Statement/v1",
subject = new[]
{
new { name = "artifact-\u4e2d\u6587", digest = new { sha256 = "abc123" } }, // Chinese characters
new { name = "artifact-\u00e9\u00e8\u00ea", digest = new { sha256 = "def456" } } // French accents
}
};
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2, "Unicode characters should hash deterministically");
}
[Fact]
public void CanonicalPayload_NumbersPreserved_HashDeterministically()
{
// Arrange - Statement with various number types
var statement = new
{
integer = 42,
negative = -17,
floating = 3.14159,
scientific = 1.5e-10,
large = 9007199254740992L
};
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2, "numbers should hash deterministically");
}
[Fact]
public void CanonicalPayload_BooleanAndNull_HashDeterministically()
{
// Arrange
var statement = new
{
active = true,
disabled = false,
missing = (string?)null
};
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2, "boolean and null values should hash deterministically");
}
[Fact]
public void DssePayload_Base64Url_DeterministicEncoding()
{
// Arrange - Create statement that would have base64url special chars
var statement = new
{
_type = "https://in-toto.io/Statement/v1",
binary = Convert.ToBase64String(new byte[] { 0xFB, 0xFF, 0xFE })
};
// Act
var hash1 = CanonJson.Hash(statement);
var hash2 = CanonJson.Hash(statement);
// Assert
hash1.Should().Be(hash2);
}
// Helper methods
private static object CreateDeterministicInTotoStatement()
{
return new
{
_type = "https://in-toto.io/Statement/v1",
predicateType = "https://slsa.dev/provenance/v1",
subject = new[]
{
new
{
name = DeterministicTestData.DefaultSubjectName,
digest = new { sha256 = DeterministicTestData.DefaultSubjectDigest }
}
},
predicate = new
{
builder = new { id = "https://github.com/stellaops/scanner-action@v2" },
buildType = "https://slsa.dev/container-build/v0.1",
invocation = new
{
configSource = new { uri = "git+https://github.com/stellaops/example@refs/heads/main" }
}
}
};
}
private static DsseEnvelope CreateDeterministicDsseEnvelope()
{
var payloadBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(CreateDeterministicInTotoStatement()));
var payloadBase64 = Convert.ToBase64String(payloadBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
return new DsseEnvelope(
Payload: payloadBase64,
PayloadType: "application/vnd.in-toto+json",
Signatures: new[]
{
new DsseSignature(
Signature: "MEUCIQD_test_signature_base64url",
KeyId: "test-key-id")
});
}
}

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,135 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public class DualSignTests
{
[Fact]
public async Task AddsSecondarySignature_WhenConfigured()
{
Environment.SetEnvironmentVariable("PQ_SOFT_ALLOWED", "1");
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new StubProvider() });
var resolver = new StubKeyResolver("primary-key", "stub");
var options = Options.Create(new DsseSignerOptions
{
KeylessAlgorithm = SignatureAlgorithms.Es256,
SecondaryAlgorithm = SignatureAlgorithms.Falcon512,
SecondaryProvider = "stub",
SecondaryKeyId = "secondary-key"
});
var signer = new CryptoDsseSigner(registry, resolver, options, NullLogger<CryptoDsseSigner>.Instance);
var request = new SigningRequest(
Subjects: Array.Empty<SigningSubject>(),
PredicateType: "demo",
Predicate: JsonDocument.Parse("{}"),
ScannerImageDigest: "sha256:dummydigest",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "ok"),
Options: new SigningOptions(SigningMode.Keyless, ExpirySeconds: null, ReturnBundle: "full"));
var entitlement = new ProofOfEntitlementResult(
LicenseId: "lic",
CustomerId: "cust",
Plan: "plan",
MaxArtifactBytes: 1024 * 1024,
QpsLimit: 10,
QpsRemaining: 10,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(5));
var caller = new CallerContext(
Subject: "subject",
Tenant: "tenant",
Scopes: Array.Empty<string>(),
Audiences: Array.Empty<string>(),
SenderBinding: null,
ClientCertificateThumbprint: null);
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
bundle.Envelope.Signatures.Should().HaveCount(2);
bundle.Envelope.Signatures[0].KeyId.Should().Be("primary-key");
bundle.Envelope.Signatures[1].KeyId.Should().Be("secondary-key");
}
private sealed class StubProvider : ICryptoProvider
{
public string Name => "stub";
public bool Supports(CryptoCapability capability, string algorithmId) =>
capability == CryptoCapability.Signing;
public IPasswordHasher GetPasswordHasher(string algorithmId) => throw new NotSupportedException();
public ICryptoHasher GetHasher(string algorithmId) => throw new NotSupportedException();
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) =>
new StubSigner(keyReference.KeyId, algorithmId);
public void UpsertSigningKey(CryptoSigningKey signingKey) { }
public bool RemoveSigningKey(string keyId) => true;
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>();
}
private sealed class StubSigner : ICryptoSigner
{
public StubSigner(string keyId, string algorithmId)
{
KeyId = keyId;
AlgorithmId = algorithmId;
}
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
var payload = System.Text.Encoding.UTF8.GetBytes($"{AlgorithmId}:{KeyId}");
return ValueTask.FromResult(payload);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true);
public Microsoft.IdentityModel.Tokens.JsonWebKey ExportPublicJsonWebKey()
=> new() { Kid = KeyId, Alg = AlgorithmId, Kty = "oct" };
}
private sealed class StubKeyResolver : ISigningKeyResolver
{
private readonly string keyId;
private readonly string provider;
public StubKeyResolver(string keyId, string provider)
{
this.keyId = keyId;
this.provider = provider;
}
public ValueTask<SigningKeyResolution> ResolveKeyAsync(SigningMode mode, string tenant, CancellationToken cancellationToken)
{
return ValueTask.FromResult(new SigningKeyResolution(
keyId,
provider,
null,
null,
null,
Array.Empty<string>()));
}
}
}

View File

@@ -0,0 +1,372 @@
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.StellaOpsPathWitness);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReachabilityDrift);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVerdict);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDelta);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbomDelta);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVerdictDelta);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReachabilityDelta);
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);
// 30 entries: SLSA (2) + StellaOps core (14) + PathWitness canonical + aliases (3) + Delta (4) + Function Map (2) + Runtime Evidence (2) + Third-party (3)
allowedTypes.Should().HaveCount(30);
}
[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

@@ -0,0 +1,121 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.SmSoft;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public class Sm2SigningTests : IDisposable
{
private readonly string? _gate;
public Sm2SigningTests()
{
_gate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
}
[Fact]
public async Task Sign_Sm2_Succeeds_WhenGateOn()
{
var registry = TestCryptoFactory.CreateSm2Registry();
var keyResolver = new StubKeyResolver("sm2-key", SignatureAlgorithms.Sm2, "cn.sm.soft");
var options = Options.Create(new DsseSignerOptions
{
KeylessAlgorithm = SignatureAlgorithms.Sm2,
KmsAlgorithm = SignatureAlgorithms.Sm2,
PreferredProvider = "cn.sm.soft"
});
var signer = new CryptoDsseSigner(
registry,
keyResolver,
options,
NullLogger<CryptoDsseSigner>.Instance);
var request = BuildRequest();
var entitlement = new ProofOfEntitlementResult("lic", "cust", "plan", 0, 0, 0, DateTimeOffset.UtcNow.AddHours(1));
var caller = BuildCaller();
var bundle = await signer.SignAsync(request, entitlement, caller, default);
Assert.Equal(SignatureAlgorithms.Sm2, bundle.Metadata.AlgorithmId);
Assert.Equal("cn.sm.soft", bundle.Metadata.ProviderName);
}
[Fact]
public async Task Sign_Sm2_Fails_WhenGateOff()
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", null);
var registry = TestCryptoFactory.CreateSm2Registry();
var keyResolver = new StubKeyResolver("sm2-key", SignatureAlgorithms.Sm2, "cn.sm.soft");
var options = Options.Create(new DsseSignerOptions { KeylessAlgorithm = SignatureAlgorithms.Sm2 });
var signer = new CryptoDsseSigner(
registry,
keyResolver,
options,
NullLogger<CryptoDsseSigner>.Instance);
var request = BuildRequest();
var entitlement = new ProofOfEntitlementResult("lic", "cust", "plan", 0, 0, 0, DateTimeOffset.UtcNow.AddHours(1));
var caller = BuildCaller();
await Assert.ThrowsAsync<InvalidOperationException>(() => signer.SignAsync(request, entitlement, caller, default).AsTask());
}
private class StubKeyResolver : ISigningKeyResolver
{
private readonly string _keyId;
private readonly string _alg;
private readonly string _provider;
public StubKeyResolver(string keyId, string alg, string provider)
{
_keyId = keyId;
_alg = alg;
_provider = provider;
}
public ValueTask<SigningKeyResolution> ResolveKeyAsync(SigningMode mode, string tenant, CancellationToken cancellationToken)
{
var resolution = new SigningKeyResolution(_keyId, _provider, "https://sm.test", "sm2-subject");
return ValueTask.FromResult(resolution);
}
}
public void Dispose()
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", _gate);
}
private static SigningRequest BuildRequest()
{
var subject = new SigningSubject("pkg", new Dictionary<string, string> { ["sha256"] = "00" });
return new SigningRequest(
new[] { subject },
"test-predicate",
JsonDocument.Parse("{}"),
"sha256:00",
new ProofOfEntitlement(SignerPoEFormat.Jwt, "stub"),
new SigningOptions(SigningMode.Keyless, null, "dsse"));
}
private static CallerContext BuildCaller() => new(
Subject: "subject-1",
Tenant: "tenant-1",
Scopes: Array.Empty<string>(),
Audiences: Array.Empty<string>(),
SenderBinding: string.Empty,
ClientCertificateThumbprint: string.Empty);
}

Some files were not shown because too many files have changed in this diff Show More