// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-08 - CLI handlers for keyless signing
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using StellaOps.Cli.Output;
using StellaOps.Signer.Infrastructure.Sigstore;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
///
/// Handle keyless signing via Sigstore (Fulcio + Rekor).
///
public static async Task HandleSignKeylessAsync(
IServiceProvider services,
string input,
string? output,
string? identityToken,
bool useRekor,
string? fulcioUrl,
string? rekorUrl,
string? oidcIssuer,
string bundleFormat,
string? caBundle,
bool insecure,
bool verbose,
CancellationToken cancellationToken)
{
if (!File.Exists(input))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
return CliExitCodes.InputFileNotFound;
}
try
{
// Resolve output path
var outputPath = output ?? $"{input}.sigstore";
// Get or detect identity token
var token = identityToken ?? await DetectAmbientIdentityTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
AnsiConsole.MarkupLine("[red]Error:[/] No identity token provided and ambient detection failed.");
AnsiConsole.MarkupLine("[dim]Provide --identity-token or run in a CI environment with OIDC support.[/]");
return CliExitCodes.MissingRequiredOption;
}
// Read artifact
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
if (verbose)
{
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
AnsiConsole.MarkupLine($"[dim]Output:[/] {outputPath}");
AnsiConsole.MarkupLine($"[dim]Rekor:[/] {(useRekor ? "enabled" : "disabled")}");
if (fulcioUrl != null) AnsiConsole.MarkupLine($"[dim]Fulcio URL:[/] {fulcioUrl}");
if (rekorUrl != null) AnsiConsole.MarkupLine($"[dim]Rekor URL:[/] {rekorUrl}");
}
// Get signing service (with option overrides)
var sigstoreService = services.GetService();
if (sigstoreService is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
AnsiConsole.MarkupLine("[dim]Ensure Sigstore is enabled in configuration.[/]");
return CliExitCodes.ServiceNotConfigured;
}
AnsiConsole.MarkupLine("[blue]Signing artifact with Sigstore keyless signing...[/]");
var result = await sigstoreService.SignKeylessAsync(
artifactBytes,
token,
cancellationToken);
// Write bundle based on format
var bundle = CreateSignatureBundle(result, bundleFormat);
await File.WriteAllTextAsync(outputPath, bundle, cancellationToken);
AnsiConsole.MarkupLine($"[green]✓[/] Signature bundle written to: [cyan]{outputPath}[/]");
AnsiConsole.MarkupLine($"[dim]Subject:[/] {result.Certificate.Subject}");
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {result.Certificate.Issuer}");
AnsiConsole.MarkupLine($"[dim]Certificate expires:[/] {result.Certificate.ExpiresAtUtc:u}");
if (result.RekorEntry != null)
{
AnsiConsole.MarkupLine($"[dim]Rekor log index:[/] {result.RekorEntry.LogIndex}");
AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {result.RekorEntry.Uuid}");
}
return CliExitCodes.Success;
}
catch (SigstoreException ex)
{
AnsiConsole.MarkupLine($"[red]Sigstore error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.SigningFailed;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.UnexpectedError;
}
}
///
/// Handle keyless signature verification.
///
public static async Task HandleVerifyKeylessAsync(
IServiceProvider services,
string input,
string? bundlePath,
string? certificatePath,
string? signaturePath,
string? rekorUuid,
string? rekorUrl,
string? expectedIssuer,
string? expectedSubject,
string? caBundle,
bool verbose,
CancellationToken cancellationToken)
{
if (!File.Exists(input))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
return CliExitCodes.InputFileNotFound;
}
try
{
// Resolve bundle or certificate+signature paths
var resolvedBundlePath = bundlePath ?? $"{input}.sigstore";
string certificate;
byte[] signature;
if (File.Exists(resolvedBundlePath))
{
// Parse bundle
var bundleJson = await File.ReadAllTextAsync(resolvedBundlePath, cancellationToken);
var bundle = JsonDocument.Parse(bundleJson);
certificate = bundle.RootElement.GetProperty("certificate").GetString() ?? string.Empty;
var sigBase64 = bundle.RootElement.GetProperty("signature").GetString() ?? string.Empty;
signature = Convert.FromBase64String(sigBase64);
if (bundle.RootElement.TryGetProperty("rekorEntry", out var rekorEntry))
{
rekorUuid ??= rekorEntry.GetProperty("uuid").GetString();
}
}
else if (certificatePath != null && signaturePath != null)
{
certificate = await File.ReadAllTextAsync(certificatePath, cancellationToken);
signature = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
}
else
{
AnsiConsole.MarkupLine("[red]Error:[/] No bundle found and --certificate/--signature not provided.");
return CliExitCodes.MissingRequiredOption;
}
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
if (verbose)
{
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
AnsiConsole.MarkupLine($"[dim]Certificate:[/] {(certificatePath ?? resolvedBundlePath)}");
if (rekorUuid != null) AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {rekorUuid}");
}
var sigstoreService = services.GetService();
if (sigstoreService is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
return CliExitCodes.ServiceNotConfigured;
}
AnsiConsole.MarkupLine("[blue]Verifying keyless signature...[/]");
var isValid = await sigstoreService.VerifyKeylessAsync(
artifactBytes,
signature,
certificate,
rekorUuid,
cancellationToken);
if (isValid)
{
AnsiConsole.MarkupLine("[green]✓[/] Signature verification [green]PASSED[/]");
// Additional policy checks
if (expectedIssuer != null || expectedSubject != null)
{
var cert = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificate);
var (subject, issuer) = ExtractCertificateIdentity(cert);
if (expectedIssuer != null && !string.Equals(issuer, expectedIssuer, StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]⚠[/] Issuer mismatch: expected '{expectedIssuer}', got '{issuer}'");
return CliExitCodes.PolicyViolation;
}
if (expectedSubject != null && !subject.Contains(expectedSubject, StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]⚠[/] Subject mismatch: expected '{expectedSubject}', got '{subject}'");
return CliExitCodes.PolicyViolation;
}
AnsiConsole.MarkupLine($"[dim]Subject:[/] {subject}");
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {issuer}");
}
return CliExitCodes.Success;
}
else
{
AnsiConsole.MarkupLine("[red]✗[/] Signature verification [red]FAILED[/]");
return CliExitCodes.VerificationFailed;
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.UnexpectedError;
}
}
///
/// Attempts to detect ambient identity token from CI environment.
///
private static Task DetectAmbientIdentityTokenAsync(CancellationToken cancellationToken)
{
// Check common CI environment variables for OIDC tokens
// Gitea Actions
var giteaToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (!string.IsNullOrEmpty(giteaToken))
{
return Task.FromResult(giteaToken);
}
// GitHub Actions
var githubToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (!string.IsNullOrEmpty(githubToken))
{
return Task.FromResult(githubToken);
}
// GitLab CI
var gitlabToken = Environment.GetEnvironmentVariable("CI_JOB_JWT_V2")
?? Environment.GetEnvironmentVariable("CI_JOB_JWT");
if (!string.IsNullOrEmpty(gitlabToken))
{
return Task.FromResult(gitlabToken);
}
// Kubernetes service account token
var k8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
if (File.Exists(k8sTokenPath))
{
var k8sToken = File.ReadAllText(k8sTokenPath);
return Task.FromResult(k8sToken);
}
return Task.FromResult(null);
}
///
/// Creates signature bundle in specified format.
///
private static string CreateSignatureBundle(SigstoreSigningResult result, string format)
{
var bundle = new
{
mediaType = "application/vnd.dev.sigstore.bundle+json;version=0.2",
certificate = result.Certificate.Certificate,
certificateChain = result.Certificate.CertificateChain,
signature = result.Signature,
publicKey = result.PublicKey,
algorithm = result.Algorithm,
sct = result.Certificate.SignedCertificateTimestamp,
rekorEntry = result.RekorEntry is not null ? new
{
uuid = result.RekorEntry.Uuid,
logIndex = result.RekorEntry.LogIndex,
integratedTime = result.RekorEntry.IntegratedTime,
logId = result.RekorEntry.LogId,
signedEntryTimestamp = result.RekorEntry.SignedEntryTimestamp
} : null,
signedAt = DateTimeOffset.UtcNow.ToString("o"),
subject = result.Certificate.Subject,
issuer = result.Certificate.Issuer
};
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
///
/// Extracts OIDC identity from Fulcio certificate.
///
private static (string Subject, string Issuer) ExtractCertificateIdentity(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
var issuer = "unknown";
var subject = cert.Subject;
foreach (var ext in cert.Extensions)
{
// Fulcio OIDC issuer extension
if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
{
issuer = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
// Fulcio OIDC subject extension
else if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.7")
{
subject = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
}
return (subject, issuer);
}
}