using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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; namespace StellaOps.Signer.Keyless; /// /// HTTP client for Sigstore Fulcio Certificate Authority. /// Implements the Fulcio v2 API for certificate signing requests. /// public sealed class HttpFulcioClient : IFulcioClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly SignerKeylessOptions _options; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public HttpFulcioClient( HttpClient httpClient, IOptions options, ILogger logger) { _httpClient = httpClient; _options = options.Value; _logger = logger; } /// public async Task 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 ParseFulcioResponse( HttpResponseMessage response, FulcioCertificateRequest originalRequest, CancellationToken cancellationToken) { var fulcioResponse = await response.Content.ReadFromJsonAsync( 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); var notBefore = new DateTimeOffset(x509Cert.NotBefore.ToUniversalTime()); var notAfter = new DateTimeOffset(x509Cert.NotAfter.ToUniversalTime()); return new FulcioCertificateResult( Certificate: leafCertBytes, CertificateChain: chainCertsBytes, SignedCertificateTimestamp: fulcioResponse.SignedCertificateEmbeddedSct?.Sct ?? string.Empty, NotBefore: notBefore, NotAfter: notAfter, 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 Certificates { get; set; } = []; } #endregion }