- 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.
345 lines
13 KiB
C#
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);
|
|
}
|
|
}
|