Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit c8f3120174
349 changed files with 78558 additions and 1342 deletions

View File

@@ -20,15 +20,22 @@ Provide cryptographic signing services for StellaOps attestations:
- **StellaOps.Signer.Core**: Core abstractions, pipeline, and contracts
- **StellaOps.Signer.Infrastructure**: Signing implementations, DI extensions
- **StellaOps.Signer.WebService**: REST API endpoints
- **StellaOps.Signer.Keyless**: Fulcio integration for keyless signing (Sprint 20251226_001)
- **StellaOps.Signer.Keyless**: Fulcio integration for keyless signing
- `IFulcioClient` / `HttpFulcioClient`: Fulcio CA HTTP client with retry/backoff
- `IEphemeralKeyGenerator` / `EphemeralKeyGenerator`: ECDSA P-256/Ed25519 ephemeral key generation
- `EphemeralKeyPair`: Secure key pair with memory zeroing on disposal
- `KeylessDsseSigner`: IDsseSigner implementation for keyless mode
- `IOidcTokenProvider` / `AmbientOidcTokenProvider`: OIDC token acquisition from CI runners
- `ICertificateChainValidator` / `CertificateChainValidator`: Fulcio chain + identity validation
- `SignerKeylessOptions`: Configuration schema for keyless mode
- **__Libraries/StellaOps.Signer.KeyManagement**: Key rotation and trust anchor management
- **__Tests**: Unit and integration tests
## Required Reading
- `docs/modules/signer/architecture.md`
- `docs/modules/signer/guides/keyless-signing.md` — Keyless signing configuration guide
- `docs/modules/signer/README.md` (if exists)
- `docs/modules/platform/architecture-overview.md`
- `docs/product-advisories/25-Dec-2025 - Planning Keyless Signing for Verdicts.md`
- Sigstore Fulcio documentation: https://docs.sigstore.dev/certificate_authority/overview/
## Working Agreement
@@ -61,8 +68,11 @@ Provide cryptographic signing services for StellaOps attestations:
- Audit every signing decision; expose metrics
- Keep Offline Kit parity in mind — document air-gapped workflows for KMS/HSM modes
## Completed Sprints
- `SPRINT_20251226_001_SIGNER_fulcio_keyless_client.md` — Fulcio keyless signing implementation (DONE)
## Active Sprints
- `SPRINT_20251226_001_SIGNER_fulcio_keyless_client.md` — Fulcio keyless signing implementation
None currently active.
## Related Modules
- **Authority**: OIDC tokens, DPoP, mTLS validation

View File

@@ -27,6 +27,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{D4E2E052-9CD5-4683-AF12-041662DEC782}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{E1A2B3C4-D5E6-47F8-9A0B-1C2D3E4F5A6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Keyless", "__Libraries\StellaOps.Signer.Keyless\StellaOps.Signer.Keyless.csproj", "{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -169,6 +173,18 @@ Global
{D4E2E052-9CD5-4683-AF12-041662DEC782}.Release|x64.Build.0 = Release|Any CPU
{D4E2E052-9CD5-4683-AF12-041662DEC782}.Release|x86.ActiveCfg = Release|Any CPU
{D4E2E052-9CD5-4683-AF12-041662DEC782}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -178,5 +194,6 @@ Global
{B4A54B6C-998B-4D8D-833F-44932500AF1B} = {93E67595-BF90-642A-D1B1-E56DFA9E06DF}
{A30EA34C-0595-4399-AD6A-4D240F87C258} = {93E67595-BF90-642A-D1B1-E56DFA9E06DF}
{0AAA68F5-D148-4B53-83D3-E486D3BAE5A0} = {93E67595-BF90-642A-D1B1-E56DFA9E06DF}
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C} = {E1A2B3C4-D5E6-47F8-9A0B-1C2D3E4F5A6B}
EndGlobalSection
EndGlobal

View File

@@ -90,6 +90,23 @@ public static class PredicateTypes
/// </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";
/// <summary>
/// CycloneDX SBOM predicate type.
/// </summary>
@@ -144,6 +161,17 @@ public static class PredicateTypes
|| predicateType == StellaOpsReachabilityDrift;
}
/// <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;
}
/// <summary>
/// Gets the list of all allowed predicate types for the Signer.
/// </summary>
@@ -167,6 +195,8 @@ public static class PredicateTypes
StellaOpsReachabilityWitness,
StellaOpsPathWitness,
StellaOpsReachabilityDrift,
StellaOpsVerdict,
StellaOpsVerdictAlt,
// Third-party types
CycloneDxSbom,
SpdxSbom,

View File

@@ -0,0 +1,190 @@
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;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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,268 @@
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;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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,83 @@
using System;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
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,196 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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 ILogger<SigstoreSigningService> _logger;
public SigstoreSigningService(
IFulcioClient fulcioClient,
IRekorClient rekorClient,
IOptions<SigstoreOptions> options,
ILogger<SigstoreSigningService> logger)
{
_fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient));
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_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 = DateTimeOffset.UtcNow;
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

@@ -67,7 +67,7 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
_testAnchorId = Guid.NewGuid();
var anchor = new TrustAnchorEntity
{
Id = _testAnchorId,
AnchorId = _testAnchorId,
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["initial-key"],
RevokedKeyIds = [],
@@ -77,14 +77,15 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
};
dbContext.TrustAnchors.Add(anchor);
dbContext.KeyHistories.Add(new KeyHistoryEntity
dbContext.KeyHistory.Add(new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = _testAnchorId,
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),
CreatedBy = "system"
CreatedAt = DateTimeOffset.UtcNow.AddMonths(-6)
});
await dbContext.SaveChangesAsync();

View File

@@ -80,7 +80,7 @@ public class TemporalKeyVerificationTests : IDisposable
var beforeKeyAdded = _key2024AddedAt.AddDays(-30); // Dec 2023
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", beforeKeyAdded);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", beforeKeyAdded);
// Assert
result.IsValid.Should().BeFalse();
@@ -96,7 +96,7 @@ public class TemporalKeyVerificationTests : IDisposable
var duringActiveWindow = _key2024AddedAt.AddMonths(3); // April 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", duringActiveWindow);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", duringActiveWindow);
// Assert
result.IsValid.Should().BeTrue();
@@ -112,7 +112,7 @@ public class TemporalKeyVerificationTests : IDisposable
var signedDuringOverlap = _key2024RevokedAt.AddDays(-30); // Dec 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", signedDuringOverlap);
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();
@@ -127,7 +127,7 @@ public class TemporalKeyVerificationTests : IDisposable
var signedAfterRevocation = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", signedAfterRevocation);
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();
@@ -143,7 +143,7 @@ public class TemporalKeyVerificationTests : IDisposable
var signedWithNewKey = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2025", signedWithNewKey);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", signedWithNewKey);
// Assert - key-2025 should be valid
result.IsValid.Should().BeTrue();
@@ -163,8 +163,8 @@ public class TemporalKeyVerificationTests : IDisposable
var duringOverlap = new DateTimeOffset(2024, 9, 15, 0, 0, 0, TimeSpan.Zero); // Sep 2024
// Act
var result2024 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", duringOverlap);
var result2025 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2025", duringOverlap);
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();
@@ -181,7 +181,7 @@ public class TemporalKeyVerificationTests : IDisposable
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act - at exact revocation time, key is already revoked
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", _key2024RevokedAt);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", _key2024RevokedAt);
// Assert - at revocation time, key should be considered revoked
result.IsValid.Should().BeFalse();
@@ -196,7 +196,7 @@ public class TemporalKeyVerificationTests : IDisposable
var justBeforeRevocation = _key2024RevokedAt.AddMilliseconds(-1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", justBeforeRevocation);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", justBeforeRevocation);
// Assert - key should still be valid
result.IsValid.Should().BeTrue();
@@ -216,7 +216,7 @@ public class TemporalKeyVerificationTests : IDisposable
var signedBeforeExpiry = expiryDate.AddDays(-30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "expiring-key", signedBeforeExpiry);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedBeforeExpiry);
// Assert - should be valid because signed before expiry
result.IsValid.Should().BeTrue();
@@ -232,7 +232,7 @@ public class TemporalKeyVerificationTests : IDisposable
var signedAfterExpiry = expiryDate.AddDays(30); // April 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "expiring-key", signedAfterExpiry);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedAfterExpiry);
// Assert - should be invalid because signed after expiry
result.IsValid.Should().BeFalse();
@@ -250,7 +250,7 @@ public class TemporalKeyVerificationTests : IDisposable
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "nonexistent-key", _currentTime);
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "nonexistent-key", _currentTime);
// Assert
result.IsValid.Should().BeFalse();
@@ -281,9 +281,9 @@ public class TemporalKeyVerificationTests : IDisposable
var checkTime = new DateTimeOffset(2024, 9, 15, 10, 30, 45, TimeSpan.Zero);
// Act - call multiple times
var result1 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
var result2 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
var result3 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
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);
@@ -301,9 +301,9 @@ public class TemporalKeyVerificationTests : IDisposable
var jstTime = new DateTimeOffset(2024, 9, 15, 21, 0, 0, TimeSpan.FromHours(9));
// Act
var resultUtc = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", utcTime);
var resultPst = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", pstTime);
var resultJst = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", jstTime);
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);
@@ -320,7 +320,7 @@ public class TemporalKeyVerificationTests : IDisposable
{
var anchor = new TrustAnchorEntity
{
Id = Guid.NewGuid(),
AnchorId = Guid.NewGuid(),
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-2024", "key-2025"],
RevokedKeyIds = ["key-2024"],
@@ -333,30 +333,32 @@ public class TemporalKeyVerificationTests : IDisposable
{
new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
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",
CreatedBy = "test-user"
CreatedAt = _key2024AddedAt
},
new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
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,
CreatedBy = "test-user"
CreatedAt = _key2025AddedAt
}
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistories.AddRange(keyHistory);
_dbContext.KeyHistory.AddRange(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
@@ -366,7 +368,7 @@ public class TemporalKeyVerificationTests : IDisposable
{
var anchor = new TrustAnchorEntity
{
Id = Guid.NewGuid(),
AnchorId = Guid.NewGuid(),
PurlPattern = "pkg:pypi/*",
AllowedKeyIds = ["expiring-key"],
RevokedKeyIds = [],
@@ -377,19 +379,20 @@ public class TemporalKeyVerificationTests : IDisposable
var keyHistory = new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
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,
CreatedBy = "test-user"
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistories.Add(keyHistory);
_dbContext.KeyHistory.Add(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
@@ -398,21 +401,4 @@ public class TemporalKeyVerificationTests : IDisposable
#endregion
}
/// <summary>
/// Fake time provider for testing temporal logic.
/// </summary>
public class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _currentTime;
public FakeTimeProvider(DateTimeOffset startTime)
{
_currentTime = startTime;
}
public override DateTimeOffset GetUtcNow() => _currentTime;
public void SetTime(DateTimeOffset newTime) => _currentTime = newTime;
public void AdvanceBy(TimeSpan duration) => _currentTime += duration;
}
// Note: FakeTimeProvider is defined in KeyRotationServiceTests.cs

View File

@@ -0,0 +1,544 @@
// -----------------------------------------------------------------------------
// 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.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));
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));
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));
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));
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));
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));
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().HaveCountGreaterOrEqualTo(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,517 @@
// -----------------------------------------------------------------------------
// 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].Sig.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().HaveCountGreaterOrEqualTo(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].Sig.Should()
.NotBe(bundle2.Envelope.Signatures[0].Sig,
"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].Sig.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>();
provider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns(new OidcTokenResult
{
IdentityToken = $"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6Intsubject}\",\"ZXhwIjo5OTk5OTk5OTk5fQ.sig",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Subject = subject,
Email = subject
});
return provider;
}
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)
{
// Create a leaf certificate signed by our mock CA
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Test Subject, 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("https://test.auth");
leafRequest.CertificateExtensions.Add(new X509Extension(issuerOid, issuerBytes, false));
var serial = new byte[16];
RandomNumberGenerator.Fill(serial);
var leafCert = leafRequest.Create(
_rootCa.CopyWithPrivateKey(_rootKey),
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddMinutes(10),
serial);
return new FulcioCertificateResult(
Certificate: leafCert.RawData,
CertificateChain: [_rootCa.RawData],
SignedCertificateTimestamp: "mock-sct",
NotBefore: new DateTimeOffset(leafCert.NotBefore, TimeSpan.Zero),
NotAfter: new DateTimeOffset(leafCert.NotAfter, TimeSpan.Zero),
Identity: new FulcioIdentity(
Issuer: "https://test.auth",
Subject: "test@test.com",
SubjectAlternativeName: "test@test.com"));
}
public void Dispose()
{
_rootCa.Dispose();
_rootKey.Dispose();
}
}
}

View File

@@ -24,6 +24,7 @@
<ProjectReference Include="..\StellaOps.Signer.Infrastructure\StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Signer.KeyManagement\StellaOps.Signer.KeyManagement.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Signer.Keyless\StellaOps.Signer.Keyless.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\StellaOps.Signer.Infrastructure\StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Signer.KeyManagement\StellaOps.Signer.KeyManagement.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

@@ -130,7 +130,7 @@ public sealed class KeyRotationService : IKeyRotationService
return new KeyRotationResult
{
Success = true,
AllowedKeyIds = anchor.AllowedKeyIds,
AllowedKeyIds = anchor.AllowedKeyIds?.AsReadOnly() ?? [],
RevokedKeyIds = revokedKeys,
AuditLogId = auditEntry.LogId
};
@@ -231,8 +231,8 @@ public sealed class KeyRotationService : IKeyRotationService
return new KeyRotationResult
{
Success = true,
AllowedKeyIds = anchor.AllowedKeyIds,
RevokedKeyIds = anchor.RevokedKeyIds,
AllowedKeyIds = anchor.AllowedKeyIds?.AsReadOnly() ?? [],
RevokedKeyIds = anchor.RevokedKeyIds?.AsReadOnly() ?? [],
AuditLogId = auditEntry.LogId
};
}

View File

@@ -0,0 +1,183 @@
// -----------------------------------------------------------------------------
// AmbientOidcTokenProvider.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0012 - Add OIDC token acquisition from Authority
// Description: OIDC token provider for ambient tokens (CI runners, workload identity)
// -----------------------------------------------------------------------------
using System.IdentityModel.Tokens.Jwt;
using Microsoft.Extensions.Logging;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// OIDC token provider that reads ambient tokens from the filesystem.
/// Used for CI runner tokens, Kubernetes workload identity, etc.
/// </summary>
public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
{
private readonly OidcAmbientConfig _config;
private readonly ILogger<AmbientOidcTokenProvider> _logger;
private readonly JwtSecurityTokenHandler _tokenHandler;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly FileSystemWatcher? _watcher;
private OidcTokenResult? _cachedToken;
private bool _disposed;
public AmbientOidcTokenProvider(
OidcAmbientConfig config,
ILogger<AmbientOidcTokenProvider> logger)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(logger);
_config = config;
_logger = logger;
_tokenHandler = new JwtSecurityTokenHandler();
if (_config.WatchForChanges && File.Exists(_config.TokenPath))
{
var directory = Path.GetDirectoryName(_config.TokenPath);
var fileName = Path.GetFileName(_config.TokenPath);
if (!string.IsNullOrEmpty(directory))
{
_watcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
};
_watcher.Changed += OnTokenFileChanged;
_watcher.EnableRaisingEvents = true;
}
}
}
/// <inheritdoc />
public string Issuer => _config.Issuer;
/// <inheritdoc />
public async Task<OidcTokenResult> AcquireTokenAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Check cache first
if (_cachedToken is not null && !_cachedToken.WillExpireSoon(TimeSpan.FromSeconds(30)))
{
return _cachedToken;
}
// Read token from file
if (!File.Exists(_config.TokenPath))
{
throw new OidcTokenAcquisitionException(
_config.Issuer,
$"Ambient token file not found: {_config.TokenPath}");
}
var tokenText = await File.ReadAllTextAsync(_config.TokenPath, cancellationToken)
.ConfigureAwait(false);
tokenText = tokenText.Trim();
if (string.IsNullOrEmpty(tokenText))
{
throw new OidcTokenAcquisitionException(
_config.Issuer,
$"Ambient token file is empty: {_config.TokenPath}");
}
// Parse JWT to extract claims
var result = ParseToken(tokenText);
_cachedToken = result;
_logger.LogDebug(
"Acquired ambient OIDC token from {TokenPath}, expires at {ExpiresAt}",
_config.TokenPath,
result.ExpiresAt);
return result;
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public OidcTokenResult? GetCachedToken()
{
var cached = _cachedToken;
if (cached is null || cached.IsExpired)
{
return null;
}
return cached;
}
/// <inheritdoc />
public void ClearCache()
{
_cachedToken = null;
}
private OidcTokenResult ParseToken(string tokenText)
{
try
{
var jwt = _tokenHandler.ReadJwtToken(tokenText);
var expiresAt = jwt.ValidTo != DateTime.MinValue
? new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)
: DateTimeOffset.UtcNow.AddHours(1); // Default if no exp claim
var subject = jwt.Subject;
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
// Validate issuer if configured
if (!string.IsNullOrEmpty(_config.Issuer))
{
var tokenIssuer = jwt.Issuer;
if (!string.Equals(tokenIssuer, _config.Issuer, StringComparison.OrdinalIgnoreCase))
{
throw new OidcTokenAcquisitionException(
_config.Issuer,
$"Token issuer '{tokenIssuer}' does not match expected issuer '{_config.Issuer}'");
}
}
return new OidcTokenResult
{
IdentityToken = tokenText,
ExpiresAt = expiresAt,
Subject = subject,
Email = email
};
}
catch (Exception ex) when (ex is not OidcTokenAcquisitionException)
{
throw new OidcTokenAcquisitionException(
_config.Issuer,
$"Failed to parse ambient token: {ex.Message}",
ex);
}
}
private void OnTokenFileChanged(object sender, FileSystemEventArgs e)
{
_logger.LogDebug("Ambient token file changed, clearing cache");
ClearCache();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_watcher?.Dispose();
_lock.Dispose();
}
}

View File

@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Default implementation of ephemeral key generation using .NET cryptographic APIs.
/// </summary>
public sealed class EphemeralKeyGenerator : IEphemeralKeyGenerator
{
private readonly ILogger<EphemeralKeyGenerator> _logger;
public EphemeralKeyGenerator(ILogger<EphemeralKeyGenerator> logger)
{
_logger = logger;
}
/// <inheritdoc />
public EphemeralKeyPair Generate(string algorithm)
{
if (!KeylessAlgorithms.IsSupported(algorithm))
{
throw new EphemeralKeyGenerationException(algorithm, $"Unsupported algorithm: {algorithm}");
}
try
{
return algorithm switch
{
KeylessAlgorithms.EcdsaP256 => GenerateEcdsaP256(),
KeylessAlgorithms.Ed25519 => GenerateEd25519(),
_ => throw new EphemeralKeyGenerationException(algorithm, $"Unsupported algorithm: {algorithm}")
};
}
catch (CryptographicException ex)
{
_logger.LogError(ex, "Failed to generate ephemeral {Algorithm} keypair", algorithm);
throw new EphemeralKeyGenerationException(algorithm, "Cryptographic key generation failed", ex);
}
}
private EphemeralKeyPair GenerateEcdsaP256()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
var publicKey = ecdsa.ExportSubjectPublicKeyInfo();
var privateKey = ecdsa.ExportECPrivateKey();
_logger.LogDebug("Generated ephemeral ECDSA P-256 keypair");
return new EphemeralKeyPair(publicKey, privateKey, KeylessAlgorithms.EcdsaP256);
}
private EphemeralKeyPair GenerateEd25519()
{
// Ed25519 support requires .NET 9+ or external library
// For now, throw NotImplementedException with guidance
throw new EphemeralKeyGenerationException(
KeylessAlgorithms.Ed25519,
"Ed25519 key generation requires additional implementation. " +
"Consider using BouncyCastle or upgrading to .NET 9+ with EdDSA support.");
}
}

View File

@@ -0,0 +1,150 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Represents an ephemeral keypair that exists only in memory.
/// Private key material is securely erased on disposal.
/// </summary>
public sealed class EphemeralKeyPair : IDisposable
{
private byte[] _privateKey;
private readonly byte[] _publicKey;
private bool _disposed;
/// <summary>
/// The public key bytes.
/// </summary>
public ReadOnlySpan<byte> PublicKey => _publicKey;
/// <summary>
/// The private key bytes. Only accessible while not disposed.
/// </summary>
/// <exception cref="ObjectDisposedException">Thrown if accessed after disposal.</exception>
public ReadOnlySpan<byte> PrivateKey
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _privateKey;
}
}
/// <summary>
/// The cryptographic algorithm used for this keypair.
/// </summary>
public string Algorithm { get; }
/// <summary>
/// The UTC timestamp when this keypair was created.
/// </summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>
/// Creates a new ephemeral keypair.
/// </summary>
/// <param name="publicKey">The public key bytes.</param>
/// <param name="privateKey">The private key bytes (will be copied).</param>
/// <param name="algorithm">The algorithm identifier.</param>
public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm)
{
ArgumentNullException.ThrowIfNull(publicKey);
ArgumentNullException.ThrowIfNull(privateKey);
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm);
_publicKey = (byte[])publicKey.Clone();
_privateKey = (byte[])privateKey.Clone();
Algorithm = algorithm;
CreatedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// Signs the specified data using the ephemeral private key.
/// </summary>
/// <param name="data">The data to sign.</param>
/// <returns>The signature bytes.</returns>
/// <exception cref="ObjectDisposedException">Thrown if called after disposal.</exception>
public byte[] Sign(ReadOnlySpan<byte> data)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return Algorithm switch
{
KeylessAlgorithms.EcdsaP256 => SignWithEcdsaP256(data),
KeylessAlgorithms.Ed25519 => SignWithEd25519(data),
_ => throw new NotSupportedException($"Unsupported algorithm: {Algorithm}")
};
}
private byte[] SignWithEcdsaP256(ReadOnlySpan<byte> data)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportECPrivateKey(_privateKey, out _);
return ecdsa.SignData(data.ToArray(), HashAlgorithmName.SHA256);
}
private byte[] SignWithEd25519(ReadOnlySpan<byte> data)
{
// Ed25519 signing implementation
// Note: .NET 9+ has native Ed25519 support via EdDSA
throw new NotImplementedException("Ed25519 signing requires additional implementation");
}
/// <summary>
/// Securely disposes the keypair, zeroing all private key material.
/// </summary>
public void Dispose()
{
if (_disposed) return;
// Zero out the private key memory
if (_privateKey != null)
{
CryptographicOperations.ZeroMemory(_privateKey);
_privateKey = [];
}
_disposed = true;
GC.SuppressFinalize(this);
}
/// <summary>
/// Finalizer ensures private key is zeroed if Dispose is not called.
/// </summary>
~EphemeralKeyPair()
{
Dispose();
}
}
/// <summary>
/// Well-known algorithm identifiers for keyless signing.
/// </summary>
public static class KeylessAlgorithms
{
/// <summary>
/// ECDSA with P-256 curve (NIST P-256, secp256r1).
/// </summary>
public const string EcdsaP256 = "ECDSA_P256";
/// <summary>
/// Edwards-curve Digital Signature Algorithm with Curve25519.
/// </summary>
public const string Ed25519 = "Ed25519";
/// <summary>
/// All supported algorithms.
/// </summary>
public static readonly IReadOnlySet<string> Supported = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
EcdsaP256,
Ed25519
};
/// <summary>
/// Validates that the specified algorithm is supported.
/// </summary>
public static bool IsSupported(string algorithm) =>
Supported.Contains(algorithm);
}

View File

@@ -0,0 +1,305 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// HTTP client for Sigstore Fulcio Certificate Authority.
/// Implements the Fulcio v2 API for certificate signing requests.
/// </summary>
public sealed class HttpFulcioClient : IFulcioClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpFulcioClient> _logger;
private readonly SignerKeylessOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public HttpFulcioClient(
HttpClient httpClient,
IOptions<SignerKeylessOptions> options,
ILogger<HttpFulcioClient> logger)
{
_httpClient = httpClient;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<FulcioCertificateResult> GetCertificateAsync(
FulcioCertificateRequest request,
CancellationToken cancellationToken = default)
{
request.Validate();
var fulcioUrl = _options.Fulcio.Url.TrimEnd('/');
var endpoint = $"{fulcioUrl}/api/v2/signingCert";
var fulcioRequest = BuildFulcioRequest(request);
var attempt = 0;
var backoff = _options.Fulcio.BackoffBase;
while (true)
{
attempt++;
try
{
_logger.LogDebug("Requesting certificate from Fulcio (attempt {Attempt})", attempt);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint);
httpRequest.Content = JsonContent.Create(fulcioRequest, options: JsonOptions);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await ParseFulcioResponse(response, request, cancellationToken);
_logger.LogInformation(
"Obtained certificate from Fulcio, valid from {NotBefore} to {NotAfter}",
result.NotBefore,
result.NotAfter);
return result;
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
// Non-retryable errors
throw new FulcioUnavailableException(
fulcioUrl,
(int)response.StatusCode,
responseBody,
$"Fulcio returned {response.StatusCode}: {responseBody}");
}
// Retryable error
if (attempt >= _options.Fulcio.Retries)
{
throw new FulcioUnavailableException(
fulcioUrl,
(int)response.StatusCode,
responseBody,
$"Fulcio returned {response.StatusCode} after {attempt} attempts");
}
_logger.LogWarning(
"Fulcio returned {StatusCode}, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
response.StatusCode,
backoff.TotalMilliseconds,
attempt,
_options.Fulcio.Retries);
}
catch (HttpRequestException ex)
{
if (attempt >= _options.Fulcio.Retries)
{
throw new FulcioUnavailableException(
fulcioUrl,
$"Failed to connect to Fulcio after {attempt} attempts",
ex);
}
_logger.LogWarning(
ex,
"Failed to connect to Fulcio, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
backoff.TotalMilliseconds,
attempt,
_options.Fulcio.Retries);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timeout
if (attempt >= _options.Fulcio.Retries)
{
throw new FulcioUnavailableException(
fulcioUrl,
$"Request to Fulcio timed out after {attempt} attempts");
}
_logger.LogWarning(
"Fulcio request timed out, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
backoff.TotalMilliseconds,
attempt,
_options.Fulcio.Retries);
}
await Task.Delay(backoff, cancellationToken);
backoff = TimeSpan.FromMilliseconds(
Math.Min(backoff.TotalMilliseconds * 2, _options.Fulcio.BackoffMax.TotalMilliseconds));
}
}
private static FulcioSigningCertRequest BuildFulcioRequest(FulcioCertificateRequest request)
{
var algorithmId = request.Algorithm switch
{
KeylessAlgorithms.EcdsaP256 => "ECDSA",
KeylessAlgorithms.Ed25519 => "ED25519",
_ => throw new ArgumentException($"Unsupported algorithm: {request.Algorithm}")
};
return new FulcioSigningCertRequest
{
Credentials = new FulcioCredentials
{
OidcIdentityToken = request.OidcIdentityToken
},
PublicKeyRequest = new FulcioPublicKeyRequest
{
PublicKey = new FulcioPublicKey
{
Algorithm = algorithmId,
Content = Convert.ToBase64String(request.PublicKey)
},
ProofOfPossession = request.ProofOfPossession
}
};
}
private async Task<FulcioCertificateResult> ParseFulcioResponse(
HttpResponseMessage response,
FulcioCertificateRequest originalRequest,
CancellationToken cancellationToken)
{
var fulcioResponse = await response.Content.ReadFromJsonAsync<FulcioSigningCertResponse>(
JsonOptions,
cancellationToken)
?? throw new FulcioUnavailableException(_options.Fulcio.Url, "Empty response from Fulcio");
var certificates = fulcioResponse.SignedCertificateEmbeddedSct?.Chain?.Certificates
?? throw new FulcioUnavailableException(_options.Fulcio.Url, "No certificates in Fulcio response");
if (certificates.Count == 0)
{
throw new FulcioUnavailableException(_options.Fulcio.Url, "Empty certificate chain in Fulcio response");
}
var leafCertPem = certificates[0];
var chainCertsPem = certificates.Skip(1).ToArray();
var leafCertBytes = ParsePemCertificate(leafCertPem);
var chainCertsBytes = chainCertsPem.Select(ParsePemCertificate).ToArray();
// Parse the leaf certificate to extract validity and identity
using var x509Cert = X509CertificateLoader.LoadCertificate(leafCertBytes);
var identity = ExtractIdentity(x509Cert);
return new FulcioCertificateResult(
Certificate: leafCertBytes,
CertificateChain: chainCertsBytes,
SignedCertificateTimestamp: fulcioResponse.SignedCertificateEmbeddedSct?.Sct ?? string.Empty,
NotBefore: new DateTimeOffset(x509Cert.NotBefore, TimeSpan.Zero),
NotAfter: new DateTimeOffset(x509Cert.NotAfter, TimeSpan.Zero),
Identity: identity);
}
private static byte[] ParsePemCertificate(string pem)
{
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
var start = pem.IndexOf(beginMarker, StringComparison.Ordinal);
var end = pem.IndexOf(endMarker, StringComparison.Ordinal);
if (start < 0 || end < 0)
{
throw new FulcioUnavailableException("", "Invalid PEM certificate format");
}
var base64 = pem[(start + beginMarker.Length)..end]
.Replace("\n", "")
.Replace("\r", "")
.Trim();
return Convert.FromBase64String(base64);
}
private static FulcioIdentity ExtractIdentity(X509Certificate2 cert)
{
var issuer = string.Empty;
var subject = cert.Subject;
string? san = null;
// Extract SAN extension
foreach (var extension in cert.Extensions)
{
if (extension.Oid?.Value == "2.5.29.17") // Subject Alternative Name
{
var asnData = new AsnEncodedData(extension.Oid, extension.RawData);
san = asnData.Format(false);
}
}
// Extract custom Fulcio extensions for OIDC issuer
foreach (var extension in cert.Extensions)
{
// Fulcio OIDC issuer OID: 1.3.6.1.4.1.57264.1.1
if (extension.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
{
issuer = Encoding.UTF8.GetString(extension.RawData).Trim();
}
}
return new FulcioIdentity(issuer, subject, san);
}
#region Fulcio API DTOs
private sealed class FulcioSigningCertRequest
{
public FulcioCredentials Credentials { get; set; } = new();
public FulcioPublicKeyRequest PublicKeyRequest { get; set; } = new();
}
private sealed class FulcioCredentials
{
public string OidcIdentityToken { get; set; } = string.Empty;
}
private sealed class FulcioPublicKeyRequest
{
public FulcioPublicKey PublicKey { get; set; } = new();
public string? ProofOfPossession { get; set; }
}
private sealed class FulcioPublicKey
{
public string Algorithm { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
private sealed class FulcioSigningCertResponse
{
public FulcioSignedCertificateEmbeddedSct? SignedCertificateEmbeddedSct { get; set; }
}
private sealed class FulcioSignedCertificateEmbeddedSct
{
public FulcioCertificateChain? Chain { get; set; }
public string? Sct { get; set; }
}
private sealed class FulcioCertificateChain
{
public List<string> Certificates { get; set; } = [];
}
#endregion
}

View File

@@ -0,0 +1,523 @@
// -----------------------------------------------------------------------------
// ICertificateChainValidator.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0011 - Implement certificate chain validation
// Description: Interface and implementation for validating Fulcio certificate chains
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Validates certificate chains from Fulcio.
/// </summary>
public interface ICertificateChainValidator
{
/// <summary>
/// Validates a certificate chain.
/// </summary>
/// <param name="leafCertificate">The leaf (signing) certificate in DER format.</param>
/// <param name="chain">The intermediate certificates in DER format.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The validation result.</returns>
Task<CertificateValidationResult> ValidateAsync(
byte[] leafCertificate,
IReadOnlyList<byte[]> chain,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates identity claims in the certificate match expectations.
/// </summary>
/// <param name="certificate">The certificate to validate.</param>
/// <returns>The identity validation result.</returns>
IdentityValidationResult ValidateIdentity(X509Certificate2 certificate);
}
/// <summary>
/// Result of certificate chain validation.
/// </summary>
public sealed record CertificateValidationResult
{
/// <summary>
/// Whether the chain is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The validated certificate chain (if valid).
/// </summary>
public X509Certificate2[]? Chain { get; init; }
/// <summary>
/// Error message if validation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Detailed chain status information.
/// </summary>
public IReadOnlyList<ChainStatusInfo>? ChainStatus { get; init; }
/// <summary>
/// The trusted root that anchored the chain (if valid).
/// </summary>
public string? TrustedRootSubject { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static CertificateValidationResult Success(
X509Certificate2[] chain,
string trustedRootSubject) => new()
{
IsValid = true,
Chain = chain,
TrustedRootSubject = trustedRootSubject
};
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static CertificateValidationResult Failure(
string errorMessage,
IReadOnlyList<ChainStatusInfo>? chainStatus = null) => new()
{
IsValid = false,
ErrorMessage = errorMessage,
ChainStatus = chainStatus
};
}
/// <summary>
/// Chain status information.
/// </summary>
public sealed record ChainStatusInfo(
string Status,
string StatusInformation);
/// <summary>
/// Result of identity validation.
/// </summary>
public sealed record IdentityValidationResult
{
/// <summary>
/// Whether the identity is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The OIDC issuer from the certificate.
/// </summary>
public string? Issuer { get; init; }
/// <summary>
/// The subject from the certificate.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Subject Alternative Names from the certificate.
/// </summary>
public IReadOnlyList<string>? SubjectAlternativeNames { get; init; }
/// <summary>
/// Error message if validation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Creates a successful identity validation result.
/// </summary>
public static IdentityValidationResult Success(
string issuer,
string subject,
IReadOnlyList<string>? sans = null) => new()
{
IsValid = true,
Issuer = issuer,
Subject = subject,
SubjectAlternativeNames = sans
};
/// <summary>
/// Creates a failed identity validation result.
/// </summary>
public static IdentityValidationResult Failure(string errorMessage) => new()
{
IsValid = false,
ErrorMessage = errorMessage
};
}
/// <summary>
/// Default implementation of <see cref="ICertificateChainValidator"/>.
/// </summary>
public sealed class CertificateChainValidator : ICertificateChainValidator
{
private readonly SignerKeylessOptions _options;
private readonly ILogger<CertificateChainValidator> _logger;
private readonly TimeProvider _timeProvider;
private readonly X509Certificate2Collection _trustedRoots;
// Fulcio-specific OIDs for OIDC claims in certificates
private const string OidFulcioIssuer = "1.3.6.1.4.1.57264.1.1"; // OIDC Issuer
private const string OidFulcioSubject = "1.3.6.1.4.1.57264.1.8"; // Subject (when no email)
private const string OidFulcioGithubWorkflow = "1.3.6.1.4.1.57264.1.2"; // GitHub Workflow Trigger
private const string OidFulcioGithubSha = "1.3.6.1.4.1.57264.1.3"; // GitHub Commit SHA
public CertificateChainValidator(
IOptions<SignerKeylessOptions> options,
ILogger<CertificateChainValidator> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_trustedRoots = LoadTrustedRoots();
}
/// <inheritdoc />
public Task<CertificateValidationResult> ValidateAsync(
byte[] leafCertificate,
IReadOnlyList<byte[]> chain,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(leafCertificate);
ArgumentNullException.ThrowIfNull(chain);
try
{
// Parse the leaf certificate
using var leaf = new X509Certificate2(leafCertificate);
// Validate certificate is not expired
var now = _timeProvider.GetUtcNow();
if (now < leaf.NotBefore)
{
return Task.FromResult(CertificateValidationResult.Failure(
$"Certificate is not yet valid. NotBefore: {leaf.NotBefore:O}"));
}
if (now > leaf.NotAfter)
{
return Task.FromResult(CertificateValidationResult.Failure(
$"Certificate has expired. NotAfter: {leaf.NotAfter:O}"));
}
// Build the chain for validation
using var chainBuilder = new X509Chain();
chainBuilder.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Fulcio certs are short-lived
chainBuilder.ChainPolicy.VerificationTime = now.DateTime;
chainBuilder.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
// Add trusted roots
foreach (var root in _trustedRoots)
{
chainBuilder.ChainPolicy.CustomTrustStore.Add(root);
}
// Add intermediate certificates
foreach (var intermediateDer in chain)
{
chainBuilder.ChainPolicy.ExtraStore.Add(new X509Certificate2(intermediateDer));
}
// Build and validate the chain
var isValid = chainBuilder.Build(leaf);
if (!isValid && _options.Certificate.ValidateChain)
{
var statusInfo = chainBuilder.ChainStatus
.Select(s => new ChainStatusInfo(s.Status.ToString(), s.StatusInformation))
.ToList();
var errorMessage = string.Join("; ", chainBuilder.ChainStatus
.Select(s => $"{s.Status}: {s.StatusInformation}"));
_logger.LogWarning(
"Certificate chain validation failed: {Error}",
errorMessage);
return Task.FromResult(CertificateValidationResult.Failure(
$"Chain validation failed: {errorMessage}",
statusInfo));
}
// Extract the chain elements
var validatedChain = chainBuilder.ChainElements
.Select(e => e.Certificate)
.ToArray();
var trustedRoot = chainBuilder.ChainElements.Count > 0
? chainBuilder.ChainElements[^1].Certificate.Subject
: "unknown";
_logger.LogDebug(
"Certificate chain validated: leaf={LeafSubject}, root={TrustedRoot}, chainLength={ChainLength}",
leaf.Subject,
trustedRoot,
validatedChain.Length);
return Task.FromResult(CertificateValidationResult.Success(validatedChain, trustedRoot));
}
catch (Exception ex)
{
_logger.LogError(ex, "Certificate chain validation error");
return Task.FromResult(CertificateValidationResult.Failure(
$"Chain validation error: {ex.Message}"));
}
}
/// <inheritdoc />
public IdentityValidationResult ValidateIdentity(X509Certificate2 certificate)
{
ArgumentNullException.ThrowIfNull(certificate);
try
{
// Extract OIDC issuer from the certificate's extensions
var issuer = ExtractExtensionValue(certificate, OidFulcioIssuer);
if (string.IsNullOrEmpty(issuer))
{
return IdentityValidationResult.Failure(
"Certificate does not contain OIDC issuer extension");
}
// Validate issuer against expected issuers
if (_options.Identity.ExpectedIssuers.Count > 0 &&
!_options.Identity.ExpectedIssuers.Contains(issuer, StringComparer.OrdinalIgnoreCase))
{
return IdentityValidationResult.Failure(
$"OIDC issuer '{issuer}' is not in the expected issuers list");
}
// Extract subject from email SAN or Fulcio subject extension
var subject = ExtractSubjectFromCertificate(certificate);
if (string.IsNullOrEmpty(subject))
{
return IdentityValidationResult.Failure(
"Certificate does not contain a valid subject identifier");
}
// Validate subject against patterns if configured
if (_options.Identity.ExpectedSubjectPatterns.Count > 0)
{
var matchesPattern = _options.Identity.ExpectedSubjectPatterns
.Any(pattern => System.Text.RegularExpressions.Regex.IsMatch(
subject, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase));
if (!matchesPattern)
{
return IdentityValidationResult.Failure(
$"Subject '{subject}' does not match any expected pattern");
}
}
// Extract all SANs
var sans = ExtractSubjectAlternativeNames(certificate);
_logger.LogDebug(
"Certificate identity validated: issuer={Issuer}, subject={Subject}, SANs={SanCount}",
issuer,
subject,
sans.Count);
return IdentityValidationResult.Success(issuer, subject, sans);
}
catch (Exception ex)
{
_logger.LogError(ex, "Identity validation error");
return IdentityValidationResult.Failure($"Identity validation error: {ex.Message}");
}
}
private X509Certificate2Collection LoadTrustedRoots()
{
var roots = new X509Certificate2Collection();
// Load from root bundle path if configured
if (!string.IsNullOrEmpty(_options.Certificate.RootBundlePath) &&
File.Exists(_options.Certificate.RootBundlePath))
{
try
{
var bundleContent = File.ReadAllText(_options.Certificate.RootBundlePath);
var certs = ParsePemCertificates(bundleContent);
foreach (var cert in certs)
{
roots.Add(cert);
}
_logger.LogInformation(
"Loaded {Count} trusted roots from {Path}",
certs.Count,
_options.Certificate.RootBundlePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load trusted roots from {Path}",
_options.Certificate.RootBundlePath);
}
}
// Add additional configured roots
foreach (var pemCert in _options.Certificate.AdditionalRoots)
{
try
{
var certs = ParsePemCertificates(pemCert);
foreach (var cert in certs)
{
roots.Add(cert);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse additional root certificate");
}
}
if (roots.Count == 0)
{
_logger.LogWarning("No trusted roots configured - chain validation may fail");
}
return roots;
}
private static List<X509Certificate2> ParsePemCertificates(string pemContent)
{
var certs = new List<X509Certificate2>();
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
var startIndex = 0;
while ((startIndex = pemContent.IndexOf(beginMarker, startIndex, StringComparison.Ordinal)) >= 0)
{
var endIndex = pemContent.IndexOf(endMarker, startIndex, StringComparison.Ordinal);
if (endIndex < 0) break;
var base64Start = startIndex + beginMarker.Length;
var base64 = pemContent[base64Start..endIndex]
.Replace("\r", "")
.Replace("\n", "");
var derBytes = Convert.FromBase64String(base64);
certs.Add(new X509Certificate2(derBytes));
startIndex = endIndex + endMarker.Length;
}
return certs;
}
private static string? ExtractExtensionValue(X509Certificate2 certificate, string oid)
{
var extension = certificate.Extensions
.OfType<X509Extension>()
.FirstOrDefault(e => e.Oid?.Value == oid);
if (extension is null) return null;
// The extension value is typically ASN.1 encoded
// For simple string values, we can decode the raw data
try
{
var rawData = extension.RawData;
if (rawData.Length >= 2 && rawData[0] == 0x0C) // UTF8String
{
var length = rawData[1];
if (rawData.Length >= 2 + length)
{
return System.Text.Encoding.UTF8.GetString(rawData, 2, length);
}
}
// Try as raw UTF8 if not properly ASN.1 encoded
return System.Text.Encoding.UTF8.GetString(rawData);
}
catch
{
return null;
}
}
private static string? ExtractSubjectFromCertificate(X509Certificate2 certificate)
{
// First, try to get email from SAN extension
var sans = ExtractSubjectAlternativeNames(certificate);
var emailSan = sans.FirstOrDefault(s => s.Contains('@'));
if (!string.IsNullOrEmpty(emailSan))
{
return emailSan;
}
// Try Fulcio subject extension
var fulcioSubject = ExtractExtensionValue(certificate, OidFulcioSubject);
if (!string.IsNullOrEmpty(fulcioSubject))
{
return fulcioSubject;
}
// Fall back to certificate subject CN
var subject = certificate.GetNameInfo(X509NameType.SimpleName, false);
return string.IsNullOrEmpty(subject) ? null : subject;
}
private static List<string> ExtractSubjectAlternativeNames(X509Certificate2 certificate)
{
var sans = new List<string>();
// Find SAN extension (OID 2.5.29.17)
var sanExtension = certificate.Extensions
.OfType<X509Extension>()
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17");
if (sanExtension is null) return sans;
// Parse the SAN extension using the AsnReader
try
{
var asnData = sanExtension.RawData;
// Simple parsing - look for email addresses and URIs
var rawString = System.Text.Encoding.UTF8.GetString(asnData);
// Extract email addresses (RFC822 names)
// This is a simplified parser; a full implementation would use proper ASN.1 parsing
// For now, we include the formatted output
var formatted = sanExtension.Format(true);
if (!string.IsNullOrEmpty(formatted))
{
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("RFC822 Name=", StringComparison.OrdinalIgnoreCase))
{
sans.Add(trimmed["RFC822 Name=".Length..]);
}
else if (trimmed.StartsWith("URI:", StringComparison.OrdinalIgnoreCase))
{
sans.Add(trimmed["URI:".Length..]);
}
else if (trimmed.StartsWith("email:", StringComparison.OrdinalIgnoreCase))
{
sans.Add(trimmed["email:".Length..]);
}
}
}
}
catch
{
// Ignore parsing errors
}
return sans;
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Generates ephemeral keypairs for keyless signing operations.
/// Ephemeral keys exist only in memory and are securely erased after use.
/// </summary>
public interface IEphemeralKeyGenerator
{
/// <summary>
/// Generates an ephemeral keypair for the specified algorithm.
/// </summary>
/// <param name="algorithm">The algorithm to use (ECDSA_P256, Ed25519).</param>
/// <returns>An ephemeral keypair that must be disposed after use.</returns>
EphemeralKeyPair Generate(string algorithm);
}

View File

@@ -0,0 +1,105 @@
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Client interface for interacting with a Sigstore Fulcio Certificate Authority.
/// Fulcio issues short-lived X.509 certificates based on OIDC identity tokens.
/// </summary>
public interface IFulcioClient
{
/// <summary>
/// Requests a signing certificate from Fulcio using an OIDC identity token.
/// </summary>
/// <param name="request">The certificate request containing public key and OIDC token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The certificate result containing the issued certificate and chain.</returns>
/// <exception cref="FulcioUnavailableException">Thrown when Fulcio is unreachable.</exception>
/// <exception cref="OidcTokenAcquisitionException">Thrown when the OIDC token is invalid.</exception>
Task<FulcioCertificateResult> GetCertificateAsync(
FulcioCertificateRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to obtain a signing certificate from Fulcio.
/// </summary>
/// <param name="PublicKey">The public key bytes in DER or PEM format.</param>
/// <param name="Algorithm">The algorithm identifier (ECDSA_P256, Ed25519).</param>
/// <param name="OidcIdentityToken">The OIDC identity token for identity binding.</param>
/// <param name="ProofOfPossession">Optional signed challenge proving key possession.</param>
public sealed record FulcioCertificateRequest(
byte[] PublicKey,
string Algorithm,
string OidcIdentityToken,
string? ProofOfPossession = null)
{
/// <summary>
/// Validates the request parameters.
/// </summary>
/// <exception cref="ArgumentException">Thrown when validation fails.</exception>
public void Validate()
{
if (PublicKey is null || PublicKey.Length == 0)
throw new ArgumentException("PublicKey is required", nameof(PublicKey));
if (string.IsNullOrWhiteSpace(Algorithm))
throw new ArgumentException("Algorithm is required", nameof(Algorithm));
if (!KeylessAlgorithms.IsSupported(Algorithm))
throw new ArgumentException($"Unsupported algorithm: {Algorithm}", nameof(Algorithm));
if (string.IsNullOrWhiteSpace(OidcIdentityToken))
throw new ArgumentException("OidcIdentityToken is required", nameof(OidcIdentityToken));
}
}
/// <summary>
/// Result of a successful certificate request from Fulcio.
/// </summary>
/// <param name="Certificate">The issued signing certificate in PEM format.</param>
/// <param name="CertificateChain">The certificate chain from leaf to root.</param>
/// <param name="SignedCertificateTimestamp">The SCT for certificate transparency.</param>
/// <param name="NotBefore">Certificate validity start time (UTC).</param>
/// <param name="NotAfter">Certificate validity end time (UTC).</param>
/// <param name="Identity">The identity bound to the certificate from the OIDC token.</param>
public sealed record FulcioCertificateResult(
byte[] Certificate,
byte[][] CertificateChain,
string SignedCertificateTimestamp,
DateTimeOffset NotBefore,
DateTimeOffset NotAfter,
FulcioIdentity Identity)
{
/// <summary>
/// Gets the certificate validity duration.
/// </summary>
public TimeSpan Validity => NotAfter - NotBefore;
/// <summary>
/// Checks if the certificate is currently valid.
/// </summary>
public bool IsValid => DateTimeOffset.UtcNow >= NotBefore && DateTimeOffset.UtcNow <= NotAfter;
/// <summary>
/// Gets the full certificate chain including the leaf certificate.
/// </summary>
public IEnumerable<byte[]> FullChain
{
get
{
yield return Certificate;
foreach (var cert in CertificateChain)
yield return cert;
}
}
}
/// <summary>
/// Identity information extracted from the OIDC token and bound to the certificate.
/// </summary>
/// <param name="Issuer">The OIDC issuer URL.</param>
/// <param name="Subject">The OIDC subject (user/service identifier).</param>
/// <param name="SubjectAlternativeName">Optional SAN extension value.</param>
public sealed record FulcioIdentity(
string Issuer,
string Subject,
string? SubjectAlternativeName = null);

View File

@@ -0,0 +1,126 @@
// -----------------------------------------------------------------------------
// IOidcTokenProvider.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0012 - Add OIDC token acquisition from Authority
// Description: Interface for obtaining OIDC tokens for Fulcio authentication
// -----------------------------------------------------------------------------
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Provides OIDC identity tokens for Fulcio authentication.
/// </summary>
public interface IOidcTokenProvider
{
/// <summary>
/// Gets the OIDC issuer URL.
/// </summary>
string Issuer { get; }
/// <summary>
/// Acquires an OIDC identity token.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The OIDC token result containing the identity token.</returns>
Task<OidcTokenResult> AcquireTokenAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a cached token if available and not expired.
/// </summary>
/// <returns>The cached token, or null if not available or expired.</returns>
OidcTokenResult? GetCachedToken();
/// <summary>
/// Clears any cached tokens.
/// </summary>
void ClearCache();
}
/// <summary>
/// Result of OIDC token acquisition.
/// </summary>
public sealed record OidcTokenResult
{
/// <summary>
/// The identity token (JWT).
/// </summary>
public required string IdentityToken { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// The subject claim from the token.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// The email claim from the token, if present.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// Whether the token is expired.
/// </summary>
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
/// <summary>
/// Whether the token will expire within the specified buffer time.
/// </summary>
public bool WillExpireSoon(TimeSpan buffer) =>
DateTimeOffset.UtcNow.Add(buffer) >= ExpiresAt;
}
/// <summary>
/// Configuration for client credentials OIDC flow.
/// </summary>
public sealed record OidcClientCredentialsConfig
{
/// <summary>
/// The OIDC issuer URL.
/// </summary>
public required string Issuer { get; init; }
/// <summary>
/// The client ID.
/// </summary>
public required string ClientId { get; init; }
/// <summary>
/// The client secret.
/// </summary>
public required string ClientSecret { get; init; }
/// <summary>
/// Additional scopes to request.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = ["openid", "email"];
/// <summary>
/// Token endpoint URL (if different from discovery).
/// </summary>
public string? TokenEndpoint { get; init; }
}
/// <summary>
/// Configuration for ambient token OIDC (CI runner tokens, workload identity).
/// </summary>
public sealed record OidcAmbientConfig
{
/// <summary>
/// The OIDC issuer URL.
/// </summary>
public required string Issuer { get; init; }
/// <summary>
/// Path to the ambient token file.
/// </summary>
public required string TokenPath { get; init; }
/// <summary>
/// Whether to watch the token file for changes.
/// </summary>
public bool WatchForChanges { get; init; } = true;
}

View File

@@ -0,0 +1,274 @@
// -----------------------------------------------------------------------------
// KeylessDsseSigner.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Task: 0007 - Implement KeylessDsseSigner
// Description: DSSE signer using ephemeral keys and Fulcio certificates
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// DSSE signer that uses ephemeral keys with Fulcio-issued short-lived certificates.
/// Implements Sigstore keyless signing workflow.
/// </summary>
public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
{
private readonly IEphemeralKeyGenerator _keyGenerator;
private readonly IFulcioClient _fulcioClient;
private readonly IOidcTokenProvider _tokenProvider;
private readonly SignerKeylessOptions _options;
private readonly ILogger<KeylessDsseSigner> _logger;
private readonly TimeProvider _timeProvider;
private bool _disposed;
public KeylessDsseSigner(
IEphemeralKeyGenerator keyGenerator,
IFulcioClient fulcioClient,
IOidcTokenProvider tokenProvider,
IOptions<SignerKeylessOptions> options,
ILogger<KeylessDsseSigner> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(keyGenerator);
ArgumentNullException.ThrowIfNull(fulcioClient);
ArgumentNullException.ThrowIfNull(tokenProvider);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_keyGenerator = keyGenerator;
_fulcioClient = fulcioClient;
_tokenProvider = tokenProvider;
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Gets the algorithm used for signing.
/// </summary>
public string Algorithm => _options.Algorithms.Preferred;
/// <inheritdoc />
public async ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
_logger.LogDebug(
"Starting keyless signing for predicate type {PredicateType}, caller: {Caller}",
request.PredicateType,
caller.Subject);
// Step 1: Acquire OIDC token
var oidcToken = await _tokenProvider.AcquireTokenAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug("Acquired OIDC token, subject: {Subject}", oidcToken.Subject);
// Step 2: Generate ephemeral key pair
using var keyPair = _keyGenerator.Generate(Algorithm);
_logger.LogDebug("Generated ephemeral {Algorithm} key pair", keyPair.Algorithm);
// Step 3: Serialize the in-toto statement
var statement = BuildInTotoStatement(request);
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(statement, InTotoJsonOptions);
// Step 4: Create proof of possession and request certificate from Fulcio
var proofOfPossession = CreateProofOfPossession(statementBytes, keyPair);
var certRequest = new FulcioCertificateRequest(
PublicKey: keyPair.PublicKey.ToArray(),
Algorithm: keyPair.Algorithm,
OidcIdentityToken: oidcToken.IdentityToken,
ProofOfPossession: Convert.ToBase64String(proofOfPossession));
var certResult = await _fulcioClient.GetCertificateAsync(certRequest, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Obtained Fulcio certificate, valid: {NotBefore} to {NotAfter}",
certResult.NotBefore,
certResult.NotAfter);
// Step 5: Create DSSE signature using the ephemeral key
var pae = CreatePreAuthenticationEncoding(request.PredicateType, statementBytes);
var signature = keyPair.Sign(pae);
// Step 6: Build the signing bundle
var bundle = BuildSigningBundle(
request,
statementBytes,
signature,
certResult,
keyPair.Algorithm);
_logger.LogInformation(
"Keyless signing complete, identity: {Subject}, subjects: {SubjectCount}",
certResult.Identity.Subject,
request.Subjects.Count);
return bundle;
}
/// <summary>
/// Builds an in-toto statement from the signing request.
/// </summary>
private static InTotoStatement BuildInTotoStatement(SigningRequest request)
{
return new InTotoStatement
{
Type = "https://in-toto.io/Statement/v0.1",
PredicateType = request.PredicateType,
Subject = request.Subjects.Select(s => new InTotoSubject
{
Name = s.Name,
Digest = s.Digest
}).ToList(),
Predicate = request.Predicate
};
}
/// <summary>
/// Builds the signing bundle with DSSE envelope and certificates.
/// </summary>
private SigningBundle BuildSigningBundle(
SigningRequest request,
byte[] statementBytes,
byte[] signature,
FulcioCertificateResult certResult,
string algorithm)
{
// Build DSSE envelope
var dsseEnvelope = new DsseEnvelope(
Payload: Convert.ToBase64String(statementBytes),
PayloadType: request.PredicateType,
Signatures:
[
new DsseSignature(
Signature: Convert.ToBase64String(signature),
KeyId: CreateKeyId(certResult.Certificate))
]);
// Build certificate chain (Base64-encoded DER)
var certChain = new List<string>
{
Convert.ToBase64String(certResult.Certificate)
};
certChain.AddRange(certResult.CertificateChain.Select(Convert.ToBase64String));
// Build signing identity
var identity = new SigningIdentity(
Mode: "keyless",
Issuer: certResult.Identity.Issuer,
Subject: certResult.Identity.Subject,
ExpiresAtUtc: certResult.NotAfter);
// Build metadata
var metadata = new SigningMetadata(
Identity: identity,
CertificateChain: certChain,
ProviderName: "fulcio",
AlgorithmId: algorithm);
return new SigningBundle(dsseEnvelope, metadata);
}
/// <summary>
/// Creates the Pre-Authentication Encoding (PAE) for DSSE.
/// PAE(payloadType, payload) = "DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP + LEN(payload) + SP + payload
/// </summary>
private static byte[] CreatePreAuthenticationEncoding(string payloadType, byte[] payload)
{
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
// Write "DSSEv1 "
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
// Write length of payload type (8 bytes, little-endian)
writer.Write((long)payloadTypeBytes.Length);
writer.Write((byte)' ');
// Write payload type
writer.Write(payloadTypeBytes);
writer.Write((byte)' ');
// Write length of payload (8 bytes, little-endian)
writer.Write((long)payload.Length);
writer.Write((byte)' ');
// Write payload
writer.Write(payload);
writer.Flush();
return ms.ToArray();
}
/// <summary>
/// Creates a proof of possession by signing a hash of the payload.
/// This proves possession of the private key to Fulcio.
/// </summary>
private static byte[] CreateProofOfPossession(byte[] payload, EphemeralKeyPair keyPair)
{
var hash = SHA256.HashData(payload);
return keyPair.Sign(hash);
}
/// <summary>
/// Creates a key ID from the certificate bytes.
/// </summary>
private static string CreateKeyId(byte[] certBytes)
{
var fingerprint = SHA256.HashData(certBytes);
return $"SHA256:{Convert.ToHexString(fingerprint).ToLowerInvariant()}";
}
private static readonly JsonSerializerOptions InTotoJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}
/// <summary>
/// In-toto statement structure.
/// </summary>
internal sealed class InTotoStatement
{
public required string Type { get; init; }
public required string PredicateType { get; init; }
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
public required JsonDocument Predicate { get; init; }
}
/// <summary>
/// In-toto subject (artifact reference).
/// </summary>
internal sealed class InTotoSubject
{
public required string Name { get; init; }
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,134 @@
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Base exception for all keyless signing errors.
/// </summary>
public abstract class KeylessSigningException : Exception
{
protected KeylessSigningException(string message) : base(message) { }
protected KeylessSigningException(string message, Exception? innerException) : base(message, innerException) { }
}
/// <summary>
/// Thrown when the Fulcio CA is unavailable or returns an error.
/// </summary>
public sealed class FulcioUnavailableException : KeylessSigningException
{
/// <summary>
/// The Fulcio URL that was unreachable.
/// </summary>
public string FulcioUrl { get; }
/// <summary>
/// The HTTP status code returned, if any.
/// </summary>
public int? HttpStatus { get; }
/// <summary>
/// The error response body, if any.
/// </summary>
public string? ResponseBody { get; }
public FulcioUnavailableException(string fulcioUrl, string message)
: base(message)
{
FulcioUrl = fulcioUrl;
}
public FulcioUnavailableException(string fulcioUrl, int httpStatus, string? responseBody, string message)
: base(message)
{
FulcioUrl = fulcioUrl;
HttpStatus = httpStatus;
ResponseBody = responseBody;
}
public FulcioUnavailableException(string fulcioUrl, string message, Exception innerException)
: base(message, innerException)
{
FulcioUrl = fulcioUrl;
}
}
/// <summary>
/// Thrown when OIDC token acquisition or validation fails.
/// </summary>
public sealed class OidcTokenAcquisitionException : KeylessSigningException
{
/// <summary>
/// The OIDC issuer that was being used.
/// </summary>
public string Issuer { get; }
/// <summary>
/// The reason for the failure.
/// </summary>
public string Reason { get; }
public OidcTokenAcquisitionException(string issuer, string reason)
: base($"Failed to acquire OIDC token from {issuer}: {reason}")
{
Issuer = issuer;
Reason = reason;
}
public OidcTokenAcquisitionException(string issuer, string reason, Exception innerException)
: base($"Failed to acquire OIDC token from {issuer}: {reason}", innerException)
{
Issuer = issuer;
Reason = reason;
}
}
/// <summary>
/// Thrown when certificate chain validation fails.
/// </summary>
public sealed class CertificateChainValidationException : KeylessSigningException
{
/// <summary>
/// The subjects in the certificate chain.
/// </summary>
public string[] ChainSubjects { get; }
/// <summary>
/// The specific validation error.
/// </summary>
public string ValidationError { get; }
public CertificateChainValidationException(string[] chainSubjects, string validationError)
: base($"Certificate chain validation failed: {validationError}")
{
ChainSubjects = chainSubjects;
ValidationError = validationError;
}
public CertificateChainValidationException(string[] chainSubjects, string validationError, Exception innerException)
: base($"Certificate chain validation failed: {validationError}", innerException)
{
ChainSubjects = chainSubjects;
ValidationError = validationError;
}
}
/// <summary>
/// Thrown when ephemeral key generation fails.
/// </summary>
public sealed class EphemeralKeyGenerationException : KeylessSigningException
{
/// <summary>
/// The algorithm that was being generated.
/// </summary>
public string Algorithm { get; }
public EphemeralKeyGenerationException(string algorithm, string message)
: base(message)
{
Algorithm = algorithm;
}
public EphemeralKeyGenerationException(string algorithm, string message, Exception innerException)
: base(message, innerException)
{
Algorithm = algorithm;
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Extension methods for registering keyless signing services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds keyless signing services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddKeylessSigning(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<SignerKeylessOptions>(
configuration.GetSection(SignerKeylessOptions.SectionName));
services.AddSingleton<IEphemeralKeyGenerator, EphemeralKeyGenerator>();
services.AddSingleton<ICertificateChainValidator, CertificateChainValidator>();
services.AddHttpClient<IFulcioClient, HttpFulcioClient>((sp, client) =>
{
var options = configuration
.GetSection(SignerKeylessOptions.SectionName)
.Get<SignerKeylessOptions>() ?? new SignerKeylessOptions();
client.BaseAddress = new Uri(options.Fulcio.Url);
client.Timeout = options.Fulcio.Timeout;
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Signer/1.0");
});
return services;
}
/// <summary>
/// Adds keyless signing services with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddKeylessSigning(
this IServiceCollection services,
Action<SignerKeylessOptions> configureOptions)
{
var options = new SignerKeylessOptions();
configureOptions(options);
services.Configure<SignerKeylessOptions>(o =>
{
o.Enabled = options.Enabled;
o.Fulcio = options.Fulcio;
o.Oidc = options.Oidc;
o.Algorithms = options.Algorithms;
o.Certificate = options.Certificate;
o.Identity = options.Identity;
});
services.AddSingleton<IEphemeralKeyGenerator, EphemeralKeyGenerator>();
services.AddSingleton<ICertificateChainValidator, CertificateChainValidator>();
services.AddHttpClient<IFulcioClient, HttpFulcioClient>((sp, client) =>
{
client.BaseAddress = new Uri(options.Fulcio.Url);
client.Timeout = options.Fulcio.Timeout;
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Signer/1.0");
});
return services;
}
}

View File

@@ -0,0 +1,170 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Signer.Keyless;
/// <summary>
/// Configuration options for keyless signing.
/// </summary>
public sealed class SignerKeylessOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Signer:Keyless";
/// <summary>
/// Whether keyless signing is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Fulcio CA configuration.
/// </summary>
public FulcioOptions Fulcio { get; set; } = new();
/// <summary>
/// OIDC configuration for token acquisition.
/// </summary>
public OidcOptions Oidc { get; set; } = new();
/// <summary>
/// Algorithm configuration.
/// </summary>
public AlgorithmOptions Algorithms { get; set; } = new();
/// <summary>
/// Certificate validation configuration.
/// </summary>
public CertificateOptions Certificate { get; set; } = new();
/// <summary>
/// Identity verification configuration.
/// </summary>
public IdentityOptions Identity { get; set; } = new();
}
/// <summary>
/// Fulcio CA configuration options.
/// </summary>
public sealed class FulcioOptions
{
/// <summary>
/// The Fulcio CA URL.
/// </summary>
[Required]
public string Url { get; set; } = "https://fulcio.sigstore.dev";
/// <summary>
/// Request timeout.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Number of retry attempts.
/// </summary>
public int Retries { get; set; } = 3;
/// <summary>
/// Base duration for exponential backoff.
/// </summary>
public TimeSpan BackoffBase { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Maximum backoff duration.
/// </summary>
public TimeSpan BackoffMax { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// OIDC configuration for token acquisition.
/// </summary>
public sealed class OidcOptions
{
/// <summary>
/// The OIDC issuer URL.
/// </summary>
public string? Issuer { get; set; }
/// <summary>
/// The OAuth2 client ID.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Reference to the client secret (e.g., "env:SIGNER_OIDC_CLIENT_SECRET").
/// </summary>
public string? ClientSecretRef { get; set; }
/// <summary>
/// Use ambient OIDC token from CI runner.
/// </summary>
public bool UseAmbientToken { get; set; }
/// <summary>
/// Path to ambient OIDC token file.
/// </summary>
public string? AmbientTokenPath { get; set; } = "/var/run/secrets/tokens/oidc";
/// <summary>
/// Token refresh interval before expiry.
/// </summary>
public TimeSpan RefreshBefore { get; set; } = TimeSpan.FromMinutes(1);
}
/// <summary>
/// Algorithm configuration options.
/// </summary>
public sealed class AlgorithmOptions
{
/// <summary>
/// Preferred algorithm for new signings.
/// </summary>
public string Preferred { get; set; } = KeylessAlgorithms.EcdsaP256;
/// <summary>
/// Allowed algorithms for signing.
/// </summary>
public List<string> Allowed { get; set; } = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519];
}
/// <summary>
/// Certificate validation configuration options.
/// </summary>
public sealed class CertificateOptions
{
/// <summary>
/// Path to Fulcio root CA bundle.
/// </summary>
public string? RootBundlePath { get; set; }
/// <summary>
/// Additional trusted root certificates (PEM format).
/// </summary>
public List<string> AdditionalRoots { get; set; } = [];
/// <summary>
/// Whether to validate the certificate chain.
/// </summary>
public bool ValidateChain { get; set; } = true;
/// <summary>
/// Whether to require Signed Certificate Timestamp (SCT).
/// </summary>
public bool RequireSct { get; set; } = true;
}
/// <summary>
/// Identity verification configuration options.
/// </summary>
public sealed class IdentityOptions
{
/// <summary>
/// Expected OIDC issuers for verification.
/// </summary>
public List<string> ExpectedIssuers { get; set; } = [];
/// <summary>
/// Expected subject patterns (regex) for SAN verification.
/// </summary>
public List<string> ExpectedSubjectPatterns { get; set; } = [];
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>Keyless signing support for StellaOps Signer using Sigstore Fulcio</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-*" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
</ItemGroup>
</Project>