using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Security.Cryptography; using System.Text.Json; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Prompts; using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Telemetry; using StellaOps.Cryptography; namespace StellaOps.Cli.Commands; internal static class CommandHandlers { public static async Task HandleScannerDownloadAsync( IServiceProvider services, string channel, string? output, bool overwrite, bool install, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-download"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "scanner download"); activity?.SetTag("stellaops.cli.channel", channel); using var duration = CliMetrics.MeasureCommandDuration("scanner download"); try { var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false); if (result.FromCache) { logger.LogInformation("Using cached scanner at {Path}.", result.Path); } else { logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes); } CliMetrics.RecordScannerDownload(channel, result.FromCache); if (install) { var installer = scope.ServiceProvider.GetRequiredService(); await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false); CliMetrics.RecordScannerInstall(channel); } Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Failed to download scanner bundle."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleScannerRunAsync( IServiceProvider services, string runner, string entry, string targetDirectory, IReadOnlyList arguments, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var executor = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-run"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal); activity?.SetTag("stellaops.cli.command", "scan run"); activity?.SetTag("stellaops.cli.runner", runner); activity?.SetTag("stellaops.cli.entry", entry); activity?.SetTag("stellaops.cli.target", targetDirectory); using var duration = CliMetrics.MeasureCommandDuration("scan run"); try { var options = scope.ServiceProvider.GetRequiredService(); var resultsDirectory = options.ResultsDirectory; var executionResult = await executor.RunAsync( runner, entry, targetDirectory, resultsDirectory, arguments, verbose, cancellationToken).ConfigureAwait(false); Environment.ExitCode = executionResult.ExitCode; CliMetrics.RecordScanRun(runner, executionResult.ExitCode); if (executionResult.ExitCode == 0) { var backend = scope.ServiceProvider.GetRequiredService(); logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath); await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false); logger.LogInformation("Scan artefact uploaded."); activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath); } else { logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode); } logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath); activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath); } catch (Exception ex) { logger.LogError(ex, "Scanner execution failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleScanUploadAsync( IServiceProvider services, string file, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-upload"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "scan upload"); activity?.SetTag("stellaops.cli.file", file); using var duration = CliMetrics.MeasureCommandDuration("scan upload"); try { var path = Path.GetFullPath(file); await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false); logger.LogInformation("Scan results uploaded successfully."); Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Failed to upload scan results."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleConnectorJobAsync( IServiceProvider services, string source, string stage, string? mode, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-connector"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db fetch"); activity?.SetTag("stellaops.cli.source", source); activity?.SetTag("stellaops.cli.stage", stage); if (!string.IsNullOrWhiteSpace(mode)) { activity?.SetTag("stellaops.cli.mode", mode); } using var duration = CliMetrics.MeasureCommandDuration("db fetch"); try { var jobKind = $"source:{source}:{stage}"; var parameters = new Dictionary(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(mode)) { parameters["mode"] = mode; } await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Connector job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleMergeJobAsync( IServiceProvider services, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-merge"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db merge"); using var duration = CliMetrics.MeasureCommandDuration("db merge"); try { await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Merge job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleExportJobAsync( IServiceProvider services, string format, bool delta, bool? publishFull, bool? publishDelta, bool? includeFull, bool? includeDelta, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-export"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db export"); activity?.SetTag("stellaops.cli.format", format); activity?.SetTag("stellaops.cli.delta", delta); using var duration = CliMetrics.MeasureCommandDuration("db export"); activity?.SetTag("stellaops.cli.publish_full", publishFull); activity?.SetTag("stellaops.cli.publish_delta", publishDelta); activity?.SetTag("stellaops.cli.include_full", includeFull); activity?.SetTag("stellaops.cli.include_delta", includeDelta); try { var jobKind = format switch { "trivy-db" or "trivy" => "export:trivy-db", _ => "export:json" }; var isTrivy = jobKind == "export:trivy-db"; if (isTrivy && !publishFull.HasValue && !publishDelta.HasValue && !includeFull.HasValue && !includeDelta.HasValue && AnsiConsole.Profile.Capabilities.Interactive) { var overrides = TrivyDbExportPrompt.PromptOverrides(); publishFull = overrides.publishFull; publishDelta = overrides.publishDelta; includeFull = overrides.includeFull; includeDelta = overrides.includeDelta; } var parameters = new Dictionary(StringComparer.Ordinal) { ["delta"] = delta }; if (publishFull.HasValue) { parameters["publishFull"] = publishFull.Value; } if (publishDelta.HasValue) { parameters["publishDelta"] = publishDelta.Value; } if (includeFull.HasValue) { parameters["includeFull"] = includeFull.Value; } if (includeDelta.HasValue) { parameters["includeDelta"] = includeDelta.Value; } await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Export job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleAuthLoginAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, bool force, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-login"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogError("Authority configuration is incomplete; unable to determine cache key."); Environment.ExitCode = 1; return; } try { if (force) { await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); } var scopeName = AuthorityTokenUtilities.ResolveScope(options); StellaOpsTokenResult token; if (!string.IsNullOrWhiteSpace(options.Authority.Username)) { if (string.IsNullOrWhiteSpace(options.Authority.Password)) { logger.LogError("Authority password must be provided when username is configured."); Environment.ExitCode = 1; return; } token = await tokenClient.RequestPasswordTokenAsync( options.Authority.Username, options.Authority.Password!, scopeName, cancellationToken).ConfigureAwait(false); } else { token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, cancellationToken).ConfigureAwait(false); } await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); if (verbose) { logger.LogInformation("Authenticated with {Authority} (scopes: {Scopes}).", options.Authority.Url, string.Join(", ", token.Scopes)); } logger.LogInformation("Login successful. Access token expires at {Expires}.", token.ExpiresAtUtc.ToString("u")); } catch (Exception ex) { logger.LogError(ex, "Authentication failed: {Message}", ex.Message); Environment.ExitCode = 1; } } public static async Task HandleAuthLogoutAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-logout"); Environment.ExitCode = 0; var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("No authority client registered; nothing to remove."); return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration missing; no cached tokens to remove."); return; } await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (verbose) { logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority"); } } public static async Task HandleAuthStatusAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-status"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("Authority client not registered; no cached tokens available."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration incomplete; no cached tokens available."); Environment.ExitCode = 1; return; } var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (entry is null) { logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url); Environment.ExitCode = 1; return; } logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u")); if (verbose) { logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); } } public static async Task HandleAuthWhoAmIAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-whoami"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("Authority client not registered; no cached tokens available."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration incomplete; no cached tokens available."); Environment.ExitCode = 1; return; } var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (entry is null) { logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url); Environment.ExitCode = 1; return; } var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password"; var now = DateTimeOffset.UtcNow; var remaining = entry.ExpiresAtUtc - now; if (remaining < TimeSpan.Zero) { remaining = TimeSpan.Zero; } logger.LogInformation("Authority: {Authority}", options.Authority.Url); logger.LogInformation("Grant type: {GrantType}", grantType); logger.LogInformation("Token type: {TokenType}", entry.TokenType); logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining)); if (entry.Scopes.Count > 0) { logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); } if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore)) { if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject)) { logger.LogInformation("Subject: {Subject}", subject); } if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId)) { logger.LogInformation("Client ID (token): {ClientId}", clientId); } if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience)) { logger.LogInformation("Audience: {Audience}", audience); } if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer)) { logger.LogInformation("Issuer: {Issuer}", issuer); } if (issuedAt is not null) { logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u")); } if (notBefore is not null) { logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u")); } var extraClaims = CollectAdditionalClaims(claims); if (extraClaims.Count > 0 && verbose) { logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims)); } } else { logger.LogInformation("Access token appears opaque; claims are unavailable."); } } public static async Task HandleAuthRevokeExportAsync( IServiceProvider services, StellaOpsCliOptions options, string? outputDirectory, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-revoke-export"); Environment.ExitCode = 0; try { var client = scope.ServiceProvider.GetRequiredService(); var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false); var directory = string.IsNullOrWhiteSpace(outputDirectory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(outputDirectory); Directory.CreateDirectory(directory); var bundlePath = Path.Combine(directory, "revocation-bundle.json"); var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws"); var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256"); await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false); var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant(); if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase)) { logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest); Environment.ExitCode = 1; return; } logger.LogInformation( "Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}, provider {Provider}).", directory, result.Sequence, result.IssuedAt, string.IsNullOrWhiteSpace(result.SigningKeyId) ? "" : result.SigningKeyId, string.IsNullOrWhiteSpace(result.SigningProvider) ? "default" : result.SigningProvider); } catch (Exception ex) { logger.LogError(ex, "Failed to export revocation bundle."); Environment.ExitCode = 1; } } public static async Task HandleAuthRevokeVerifyAsync( string bundlePath, string signaturePath, string keyPath, bool verbose, CancellationToken cancellationToken) { var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options => { options.SingleLine = true; options.TimestampFormat = "HH:mm:ss "; })); var logger = loggerFactory.CreateLogger("auth-revoke-verify"); Environment.ExitCode = 0; try { if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath)) { logger.LogError("Arguments --bundle, --signature, and --key are required."); Environment.ExitCode = 1; return; } var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false); var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim(); var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false); var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant(); logger.LogInformation("Bundle digest sha256:{Digest}", digest); if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature)) { logger.LogError("Signature is not in detached JWS format."); Environment.ExitCode = 1; return; } var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader)); using var headerDocument = JsonDocument.Parse(headerJson); var header = headerDocument.RootElement; if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean()) { logger.LogError("Detached JWS header must include '\"b64\": false'."); Environment.ExitCode = 1; return; } var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256; if (string.IsNullOrWhiteSpace(algorithm)) { algorithm = SignatureAlgorithms.Es256; } var providerHint = header.TryGetProperty("provider", out var providerElement) ? providerElement.GetString() : null; var keyId = header.TryGetProperty("kid", out var kidElement) ? kidElement.GetString() : null; if (string.IsNullOrWhiteSpace(keyId)) { keyId = Path.GetFileNameWithoutExtension(keyPath); logger.LogWarning("JWS header missing 'kid'; using fallback key id {KeyId}.", keyId); } CryptoSigningKey signingKey; try { signingKey = CreateVerificationSigningKey(keyId!, algorithm!, providerHint, keyPem, keyPath); } catch (Exception ex) when (ex is InvalidOperationException or CryptographicException) { logger.LogError(ex, "Failed to load verification key material."); Environment.ExitCode = 1; return; } var providers = new List { new DefaultCryptoProvider() }; #if STELLAOPS_CRYPTO_SODIUM providers.Add(new LibsodiumCryptoProvider()); #endif foreach (var provider in providers) { if (provider.Supports(CryptoCapability.Verification, algorithm!)) { provider.UpsertSigningKey(signingKey); } } var preferredOrder = !string.IsNullOrWhiteSpace(providerHint) ? new[] { providerHint! } : Array.Empty(); var registry = new CryptoProviderRegistry(providers, preferredOrder); CryptoSignerResolution resolution; try { resolution = registry.ResolveSigner( CryptoCapability.Verification, algorithm!, signingKey.Reference, providerHint); } catch (Exception ex) { logger.LogError(ex, "No crypto provider available for verification (algorithm {Algorithm}).", algorithm); Environment.ExitCode = 1; return; } var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length; var buffer = ArrayPool.Shared.Rent(signingInputLength); try { var headerBytes = Encoding.ASCII.GetBytes(encodedHeader); Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length); buffer[headerBytes.Length] = (byte)'.'; Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length); var signatureBytes = Base64UrlDecode(encodedSignature); var verified = await resolution.Signer.VerifyAsync( new ReadOnlyMemory(buffer, 0, signingInputLength), signatureBytes, cancellationToken).ConfigureAwait(false); if (!verified) { logger.LogError("Signature verification failed."); Environment.ExitCode = 1; return; } } finally { ArrayPool.Shared.Return(buffer); } if (!string.IsNullOrWhiteSpace(providerHint) && !string.Equals(providerHint, resolution.ProviderName, StringComparison.OrdinalIgnoreCase)) { logger.LogWarning( "Preferred provider '{Preferred}' unavailable; verification used '{Provider}'.", providerHint, resolution.ProviderName); } logger.LogInformation( "Signature verified using algorithm {Algorithm} via provider {Provider} (kid {KeyId}).", algorithm, resolution.ProviderName, signingKey.Reference.KeyId); if (verbose) { logger.LogInformation("JWS header: {Header}", headerJson); } } catch (Exception ex) { logger.LogError(ex, "Failed to verify revocation bundle."); Environment.ExitCode = 1; } finally { loggerFactory.Dispose(); } } private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature) { encodedHeader = string.Empty; encodedSignature = string.Empty; if (string.IsNullOrWhiteSpace(value)) { return false; } var parts = value.Split('.'); if (parts.Length != 3) { return false; } encodedHeader = parts[0]; encodedSignature = parts[2]; return parts[1].Length == 0; } private static byte[] Base64UrlDecode(string value) { var normalized = value.Replace('-', '+').Replace('_', '/'); var padding = normalized.Length % 4; if (padding == 2) { normalized += "=="; } else if (padding == 3) { normalized += "="; } else if (padding == 1) { throw new FormatException("Invalid Base64Url value."); } return Convert.FromBase64String(normalized); } private static CryptoSigningKey CreateVerificationSigningKey( string keyId, string algorithm, string? providerHint, string keyPem, string keyPath) { if (string.IsNullOrWhiteSpace(keyPem)) { throw new InvalidOperationException("Verification key PEM content is empty."); } using var ecdsa = ECDsa.Create(); ecdsa.ImportFromPem(keyPem); var parameters = ecdsa.ExportParameters(includePrivateParameters: false); if (parameters.D is null || parameters.D.Length == 0) { parameters.D = new byte[] { 0x01 }; } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["source"] = Path.GetFullPath(keyPath), ["verificationOnly"] = "true" }; return new CryptoSigningKey( new CryptoKeyReference(keyId, providerHint), algorithm, in parameters, DateTimeOffset.UtcNow, metadata: metadata); } private static string FormatDuration(TimeSpan duration) { if (duration <= TimeSpan.Zero) { return "expired"; } if (duration.TotalDays >= 1) { var days = (int)duration.TotalDays; var hours = duration.Hours; return hours > 0 ? FormattableString.Invariant($"{days}d {hours}h") : FormattableString.Invariant($"{days}d"); } if (duration.TotalHours >= 1) { return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m"); } if (duration.TotalMinutes >= 1) { return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s"); } return FormattableString.Invariant($"{duration.Seconds}s"); } private static bool TryExtractJwtClaims( string accessToken, out Dictionary claims, out DateTimeOffset? issuedAt, out DateTimeOffset? notBefore) { claims = new Dictionary(StringComparer.OrdinalIgnoreCase); issuedAt = null; notBefore = null; if (string.IsNullOrWhiteSpace(accessToken)) { return false; } var parts = accessToken.Split('.'); if (parts.Length < 2) { return false; } if (!TryDecodeBase64Url(parts[1], out var payloadBytes)) { return false; } try { using var document = JsonDocument.Parse(payloadBytes); foreach (var property in document.RootElement.EnumerateObject()) { var value = FormatJsonValue(property.Value); claims[property.Name] = value; if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat)) { issuedAt = parsedIat; } if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf)) { notBefore = parsedNbf; } } return true; } catch (JsonException) { claims.Clear(); issuedAt = null; notBefore = null; return false; } } private static bool TryDecodeBase64Url(string value, out byte[] bytes) { bytes = Array.Empty(); if (string.IsNullOrWhiteSpace(value)) { return false; } var normalized = value.Replace('-', '+').Replace('_', '/'); var padding = normalized.Length % 4; if (padding is 2 or 3) { normalized = normalized.PadRight(normalized.Length + (4 - padding), '='); } else if (padding == 1) { return false; } try { bytes = Convert.FromBase64String(normalized); return true; } catch (FormatException) { return false; } } private static string FormatJsonValue(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString() ?? string.Empty, JsonValueKind.Number => element.TryGetInt64(out var longValue) ? longValue.ToString(CultureInfo.InvariantCulture) : element.GetDouble().ToString(CultureInfo.InvariantCulture), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => "null", JsonValueKind.Array => FormatArray(element), JsonValueKind.Object => element.GetRawText(), _ => element.GetRawText() }; } private static string FormatArray(JsonElement array) { var values = new List(); foreach (var item in array.EnumerateArray()) { values.Add(FormatJsonValue(item)); } return string.Join(", ", values); } private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value) { value = default; if (element.ValueKind == JsonValueKind.Number) { if (element.TryGetInt64(out var seconds)) { value = DateTimeOffset.FromUnixTimeSeconds(seconds); return true; } if (element.TryGetDouble(out var doubleValue)) { value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue); return true; } } if (element.ValueKind == JsonValueKind.String) { var text = element.GetString(); if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) { value = DateTimeOffset.FromUnixTimeSeconds(seconds); return true; } } return false; } private static List CollectAdditionalClaims(Dictionary claims) { var result = new List(); foreach (var pair in claims) { if (CommonClaimNames.Contains(pair.Key)) { continue; } result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}")); } result.Sort(StringComparer.OrdinalIgnoreCase); return result; } private static readonly HashSet CommonClaimNames = new(StringComparer.OrdinalIgnoreCase) { "aud", "client_id", "exp", "iat", "iss", "nbf", "scope", "scopes", "sub", "token_type", "jti" }; private static async Task TriggerJobAsync( IBackendOperationsClient client, ILogger logger, string jobKind, IDictionary parameters, CancellationToken cancellationToken) { JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false); if (result.Success) { if (!string.IsNullOrWhiteSpace(result.Location)) { logger.LogInformation("Job accepted. Track status at {Location}.", result.Location); } else if (result.Run is not null) { logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status); } else { logger.LogInformation("Job accepted."); } Environment.ExitCode = 0; } else { logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message); Environment.ExitCode = 1; } } }