310 lines
11 KiB
C#
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
|
|
}
|