Files
git.stella-ops.org/src/StellaOps.Cli/Commands/CommandHandlers.cs

1146 lines
42 KiB
C#

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<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<IScannerInstaller>();
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<string> arguments,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<StellaOpsCliOptions>();
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<IBackendOperationsClient>();
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<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<string, object?>(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<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<string, object?>(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<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
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<string, object?>(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<ILoggerFactory>().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<IStellaOpsTokenClient>();
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<ILoggerFactory>().CreateLogger("auth-logout");
Environment.ExitCode = 0;
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
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<ILoggerFactory>().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<IStellaOpsTokenClient>();
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<ILoggerFactory>().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<IStellaOpsTokenClient>();
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<ILoggerFactory>().CreateLogger("auth-revoke-export");
Environment.ExitCode = 0;
try
{
var client = scope.ServiceProvider.GetRequiredService<IAuthorityRevocationClient>();
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) ? "<unknown>" : 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<ICryptoProvider>
{
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<string>();
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<byte>.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<byte>(buffer, 0, signingInputLength),
signatureBytes,
cancellationToken).ConfigureAwait(false);
if (!verified)
{
logger.LogError("Signature verification failed.");
Environment.ExitCode = 1;
return;
}
}
finally
{
ArrayPool<byte>.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<string, string?>(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<string, string> claims,
out DateTimeOffset? issuedAt,
out DateTimeOffset? notBefore)
{
claims = new Dictionary<string, string>(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<byte>();
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<string>();
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<string> CollectAdditionalClaims(Dictionary<string, string> claims)
{
var result = new List<string>();
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<string> 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<string, object?> 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;
}
}
}