Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Sign.cs
StellaOps Bot 907783f625 Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
2025-12-26 15:17:58 +02:00

345 lines
13 KiB
C#

// 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
{
/// <summary>
/// Handle keyless signing via Sigstore (Fulcio + Rekor).
/// </summary>
public static async Task<int> 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<ISigstoreSigningService>();
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;
}
}
/// <summary>
/// Handle keyless signature verification.
/// </summary>
public static async Task<int> 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<ISigstoreSigningService>();
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;
}
}
/// <summary>
/// Attempts to detect ambient identity token from CI environment.
/// </summary>
private static Task<string?> 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<string?>(giteaToken);
}
// GitHub Actions
var githubToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (!string.IsNullOrEmpty(githubToken))
{
return Task.FromResult<string?>(githubToken);
}
// GitLab CI
var gitlabToken = Environment.GetEnvironmentVariable("CI_JOB_JWT_V2")
?? Environment.GetEnvironmentVariable("CI_JOB_JWT");
if (!string.IsNullOrEmpty(gitlabToken))
{
return Task.FromResult<string?>(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<string?>(k8sToken);
}
return Task.FromResult<string?>(null);
}
/// <summary>
/// Creates signature bundle in specified format.
/// </summary>
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
});
}
/// <summary>
/// Extracts OIDC identity from Fulcio certificate.
/// </summary>
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);
}
}