Files
git.stella-ops.org/src/Attestor/__Libraries/StellaOps.Signer.Keyless/HttpFulcioClient.cs

310 lines
11 KiB
C#

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;
/// <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);
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<string> Certificates { get; set; } = [];
}
#endregion
}