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 _options; private readonly ILogger _logger; public Rfc3161TimestampAuthorityClient( HttpClient httpClient, IOptions options, ILogger 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 RequestTimestampAsync( ReadOnlyMemory 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 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 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}'.") }; }