// 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); } }