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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
105
src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs
Normal file
105
src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user