Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Signing/Rfc3161TimestampAuthorityClient.cs
master 2eb6852d34
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for SBOM ingestion and transformation
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
2025-11-04 07:49:39 +02:00

173 lines
6.7 KiB
C#

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Nist;
using Org.BouncyCastle.Asn1.Oiw;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Tsp;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Signing;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
public sealed class Rfc3161TimestampAuthorityClient : ITimestampAuthorityClient
{
private readonly HttpClient _httpClient;
private readonly IOptions<EvidenceLockerOptions> _options;
private readonly ILogger<Rfc3161TimestampAuthorityClient> _logger;
public Rfc3161TimestampAuthorityClient(
HttpClient httpClient,
IOptions<EvidenceLockerOptions> options,
ILogger<Rfc3161TimestampAuthorityClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
var timestamping = _options.Value.Signing?.Timestamping;
if (timestamping is null || !timestamping.Enabled)
{
return null;
}
if (string.IsNullOrWhiteSpace(timestamping.Endpoint))
{
throw new InvalidOperationException("Timestamping endpoint must be configured when enabled.");
}
var digest = ComputeDigest(signature.Span, hashAlgorithm);
var hashOid = ResolveHashAlgorithmOid(hashAlgorithm);
var requestGenerator = new TimeStampRequestGenerator();
requestGenerator.SetCertReq(true);
Span<byte> nonceBuffer = stackalloc byte[16];
RandomNumberGenerator.Fill(nonceBuffer);
var nonce = new BigInteger(1, nonceBuffer);
var timeStampRequest = requestGenerator.Generate(new DerObjectIdentifier(hashOid), digest, nonce);
var requestBytes = timeStampRequest.GetEncoded();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, timestamping.Endpoint);
httpRequest.Content = new ByteArrayContent(requestBytes);
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
httpRequest.Headers.Accept.Clear();
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/timestamp-reply"));
httpRequest.Headers.UserAgent.ParseAdd("StellaOpsEvidenceLocker/1.0");
if (timestamping.RequestTimeoutSeconds > 0)
{
_httpClient.Timeout = TimeSpan.FromSeconds(timestamping.RequestTimeoutSeconds);
}
if (timestamping.Authentication is { Username: { Length: > 0 } } auth)
{
var credentials = $"{auth.Username}:{auth.Password ?? string.Empty}";
var credentialBytes = Encoding.UTF8.GetBytes(credentials);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes));
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException($"Timestamp authority responded with status code {(int)response.StatusCode} ({response.StatusCode}).");
}
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning("Timestamp authority request failed with status {StatusCode}.", response.StatusCode);
}
return null;
}
var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
TimeStampResponse tspResponse;
try
{
tspResponse = new TimeStampResponse(responseBytes);
tspResponse.Validate(timeStampRequest);
}
catch (Exception ex) when (!timestamping.RequireTimestamp)
{
_logger.LogWarning(ex, "Timestamp authority returned an invalid response.");
return null;
}
if (tspResponse.Status is not 0 and not 1)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException($"Timestamp authority declined request with status code {tspResponse.Status}.");
}
_logger.LogWarning("Timestamp authority declined request with status code {Status}.", tspResponse.Status);
return null;
}
var token = tspResponse.TimeStampToken;
if (token is null)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException("Timestamp authority response did not include a token.");
}
_logger.LogWarning("Timestamp authority response missing token for bundle timestamp request.");
return null;
}
var info = token.TimeStampInfo;
var authority = info.Tsa?.Name?.ToString() ?? timestamping.Endpoint;
var tokenBytes = token.GetEncoded();
return new TimestampResult(info.GenTime, authority, tokenBytes);
}
private static byte[] ComputeDigest(ReadOnlySpan<byte> data, string algorithm)
{
var hashAlgorithm = GetHashAlgorithmName(algorithm);
using var hasher = IncrementalHash.CreateHash(hashAlgorithm);
hasher.AppendData(data);
return hasher.GetHashAndReset();
}
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
{
"SHA256" => HashAlgorithmName.SHA256,
"SHA384" => HashAlgorithmName.SHA384,
"SHA512" => HashAlgorithmName.SHA512,
"SHA1" => HashAlgorithmName.SHA1,
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
};
private static string ResolveHashAlgorithmOid(string algorithm)
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
{
"SHA256" => NistObjectIdentifiers.IdSha256.Id,
"SHA384" => NistObjectIdentifiers.IdSha384.Id,
"SHA512" => NistObjectIdentifiers.IdSha512.Id,
"SHA1" => OiwObjectIdentifiers.IdSha1.Id,
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
};
}