using System; using System.Buffers.Text; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); private readonly HttpClient httpClient; private readonly StellaOpsCliOptions options; private readonly ILogger logger; private readonly IStellaOpsTokenClient? tokenClient; private readonly object tokenSync = new(); private string? cachedAccessToken; private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public AuthorityRevocationClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.options = options ?? throw new ArgumentNullException(nameof(options)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.tokenClient = tokenClient; if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri)) { httpClient.BaseAddress = authorityUri; } } public async Task ExportAsync(bool verbose, CancellationToken cancellationToken) { EnsureAuthorityConfigured(); using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export"); var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(accessToken)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); } using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}"; throw new InvalidOperationException(message); } var payload = await JsonSerializer.DeserializeAsync( await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), SerializerOptions, cancellationToken).ConfigureAwait(false); if (payload is null) { throw new InvalidOperationException("Authority export response payload was empty."); } var bundleBytes = Convert.FromBase64String(payload.Bundle.Data); var digest = payload.Digest?.Value ?? string.Empty; if (verbose) { logger.LogInformation("Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}).", payload.Sequence, digest, payload.SigningKeyId ?? ""); } return new AuthorityRevocationExportResult { BundleBytes = bundleBytes, Signature = payload.Signature?.Value ?? string.Empty, Digest = digest, Sequence = payload.Sequence, IssuedAt = payload.IssuedAt, SigningKeyId = payload.SigningKeyId }; } private async Task AcquireAccessTokenAsync(CancellationToken cancellationToken) { if (tokenClient is null) { return null; } lock (tokenSync) { if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow) { return cachedAccessToken; } } var scope = AuthorityTokenUtilities.ResolveScope(options); var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false); lock (tokenSync) { cachedAccessToken = token.AccessToken; cachedAccessTokenExpiresAt = token.ExpiresAtUtc; return cachedAccessToken; } } private async Task RequestAccessTokenAsync(string scope, CancellationToken cancellationToken) { if (options.Authority is null) { throw new InvalidOperationException("Authority credentials are not configured."); } if (!string.IsNullOrWhiteSpace(options.Authority.Username)) { if (string.IsNullOrWhiteSpace(options.Authority.Password)) { throw new InvalidOperationException("Authority password must be configured or run 'auth login'."); } return await tokenClient!.RequestPasswordTokenAsync( options.Authority.Username, options.Authority.Password!, scope, cancellationToken).ConfigureAwait(false); } return await tokenClient!.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false); } private void EnsureAuthorityConfigured() { if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml."); } if (httpClient.BaseAddress is null) { if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri)) { throw new InvalidOperationException("Authority URL is invalid."); } httpClient.BaseAddress = baseUri; } } private sealed class ExportResponseDto { [JsonPropertyName("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; [JsonPropertyName("bundleId")] public string BundleId { get; set; } = string.Empty; [JsonPropertyName("sequence")] public long Sequence { get; set; } [JsonPropertyName("issuedAt")] public DateTimeOffset IssuedAt { get; set; } [JsonPropertyName("signingKeyId")] public string? SigningKeyId { get; set; } [JsonPropertyName("bundle")] public ExportPayloadDto Bundle { get; set; } = new(); [JsonPropertyName("signature")] public ExportSignatureDto? Signature { get; set; } [JsonPropertyName("digest")] public ExportDigestDto? Digest { get; set; } } private sealed class ExportPayloadDto { [JsonPropertyName("data")] public string Data { get; set; } = string.Empty; } private sealed class ExportSignatureDto { [JsonPropertyName("algorithm")] public string Algorithm { get; set; } = string.Empty; [JsonPropertyName("keyId")] public string KeyId { get; set; } = string.Empty; [JsonPropertyName("value")] public string Value { get; set; } = string.Empty; } private sealed class ExportDigestDto { [JsonPropertyName("value")] public string Value { get; set; } = string.Empty; } }