consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user