Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
2026-01-12 12:24:17 +02:00

1128 lines
40 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
using StellaOps.Cryptography;
namespace StellaOps.Cli.Services;
/// <summary>
/// Assembler for promotion attestations.
/// Per CLI-PROMO-70-001.
/// </summary>
internal sealed partial class PromotionAssembler : IPromotionAssembler
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<PromotionAssembler> _logger;
private readonly TimeProvider _timeProvider;
public PromotionAssembler(
HttpClient httpClient,
ICryptoHash cryptoHash,
ILogger<PromotionAssembler> logger,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PromotionAssembleResult> AssembleAsync(
PromotionAssembleRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
var warnings = new List<string>();
var materials = new List<PromotionMaterial>();
_logger.LogDebug("Assembling promotion attestation for image {Image}", request.Image);
// Resolve image digest
string imageDigest;
try
{
var resolved = await ResolveImageDigestAsync(request.Image, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(resolved))
{
errors.Add($"Failed to resolve image digest for {request.Image}");
return new PromotionAssembleResult
{
Success = false,
Errors = errors
};
}
imageDigest = resolved;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve image digest");
errors.Add($"Failed to resolve image digest: {ex.Message}");
return new PromotionAssembleResult
{
Success = false,
Errors = errors
};
}
// Parse image reference
var (imageName, _) = ParseImageRef(request.Image);
// Hash SBOM
if (!string.IsNullOrWhiteSpace(request.SbomPath))
{
if (!File.Exists(request.SbomPath))
{
errors.Add($"SBOM file not found: {request.SbomPath}");
}
else
{
var sbomDigest = await ComputeFileDigestAsync(request.SbomPath, cancellationToken).ConfigureAwait(false);
var format = DetectSbomFormat(request.SbomPath);
materials.Add(new PromotionMaterial
{
Role = "sbom",
Algo = "sha256",
Digest = sbomDigest,
Format = format,
Uri = $"file://{Path.GetFileName(request.SbomPath)}"
});
}
}
else
{
warnings.Add("No SBOM provided; promotion attestation will not include SBOM reference");
}
// Hash VEX
if (!string.IsNullOrWhiteSpace(request.VexPath))
{
if (!File.Exists(request.VexPath))
{
errors.Add($"VEX file not found: {request.VexPath}");
}
else
{
var vexDigest = await ComputeFileDigestAsync(request.VexPath, cancellationToken).ConfigureAwait(false);
var format = DetectVexFormat(request.VexPath);
materials.Add(new PromotionMaterial
{
Role = "vex",
Algo = "sha256",
Digest = vexDigest,
Format = format,
Uri = $"file://{Path.GetFileName(request.VexPath)}"
});
}
}
else
{
warnings.Add("No VEX provided; promotion attestation will not include VEX reference");
}
if (errors.Count > 0)
{
return new PromotionAssembleResult
{
Success = false,
ImageDigest = imageDigest,
Materials = materials,
Errors = errors,
Warnings = warnings
};
}
// Rekor entry (skip for now if requested or if Attestor is not available)
PromotionRekorEntry? rekorEntry = null;
if (!request.SkipRekor)
{
warnings.Add("Rekor integration requires Attestor API access; skipping transparency log entry");
}
// Build predicate
var predicate = new PromotionPredicate
{
Type = "stella.ops/promotion@v1",
Subject = new[]
{
new PromotionSubject
{
Name = imageName,
Digest = new Dictionary<string, string> { ["sha256"] = imageDigest }
}
},
Materials = materials,
Promotion = new PromotionMetadata
{
From = request.FromEnvironment,
To = request.ToEnvironment,
Actor = request.Actor ?? Environment.UserName,
Timestamp = _timeProvider.GetUtcNow(),
Pipeline = request.Pipeline,
Ticket = request.Ticket,
Notes = request.Notes
},
Rekor = rekorEntry
};
// Write output
string? outputPath = null;
if (!string.IsNullOrWhiteSpace(request.OutputPath))
{
outputPath = request.OutputPath;
var json = JsonSerializer.Serialize(predicate, SerializerOptions);
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Wrote promotion attestation to {OutputPath}", outputPath);
}
return new PromotionAssembleResult
{
Success = true,
Predicate = predicate,
OutputPath = outputPath,
ImageDigest = imageDigest,
Materials = materials,
RekorEntry = rekorEntry,
Warnings = warnings
};
}
public async Task<string?> ResolveImageDigestAsync(
string imageRef,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageRef);
// If already contains digest, extract it
if (imageRef.Contains("@sha256:", StringComparison.OrdinalIgnoreCase))
{
var match = DigestRegex().Match(imageRef);
if (match.Success)
{
return match.Groups[1].Value;
}
}
// Try using crane command if available
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "crane",
Arguments = $"digest {imageRef}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
{
var digest = stdout.Trim();
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return digest[7..];
}
return digest;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "crane command not available or failed");
}
// Try using cosign triangulate
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "cosign",
Arguments = $"triangulate {imageRef}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
{
// cosign triangulate returns the signature tag location
// Extract digest if present
var match = DigestRegex().Match(stdout);
if (match.Success)
{
return match.Groups[1].Value;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "cosign command not available or failed");
}
_logger.LogWarning("Could not resolve image digest; crane/cosign not available");
return null;
}
private async Task<string> ComputeFileDigestAsync(string filePath, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(filePath);
return await _cryptoHash.ComputeHashHexForPurposeAsync(stream, HashPurpose.Content, cancellationToken).ConfigureAwait(false);
}
private static (string name, string? tag) ParseImageRef(string imageRef)
{
var digestIndex = imageRef.IndexOf('@');
if (digestIndex >= 0)
{
return (imageRef[..digestIndex], null);
}
var tagIndex = imageRef.LastIndexOf(':');
if (tagIndex >= 0 && !imageRef[(tagIndex + 1)..].Contains('/'))
{
return (imageRef[..tagIndex], imageRef[(tagIndex + 1)..]);
}
return (imageRef, null);
}
private static string DetectSbomFormat(string filePath)
{
var fileName = Path.GetFileName(filePath).ToLowerInvariant();
if (fileName.Contains("cyclonedx"))
return "CycloneDX-1.6";
if (fileName.Contains("spdx"))
return "SPDX-3.0";
try
{
var content = File.ReadAllText(filePath);
if (content.Contains("\"bomFormat\"") && content.Contains("\"CycloneDX\""))
return "CycloneDX-1.6";
if (content.Contains("SPDXVersion") || content.Contains("spdxVersion"))
return "SPDX-3.0";
}
catch
{
// Ignore read errors
}
return "unknown";
}
private static string DetectVexFormat(string filePath)
{
var fileName = Path.GetFileName(filePath).ToLowerInvariant();
if (fileName.Contains("openvex"))
return "OpenVEX-1.0";
if (fileName.Contains("csaf"))
return "CSAF-2.0";
try
{
var content = File.ReadAllText(filePath);
if (content.Contains("\"@context\"") && content.Contains("openvex"))
return "OpenVEX-1.0";
if (content.Contains("\"document\"") && content.Contains("\"csaf\""))
return "CSAF-2.0";
}
catch
{
// Ignore read errors
}
return "unknown";
}
// CLI-PROMO-70-002: Attest implementation
public async Task<PromotionAttestResult> AttestAsync(
PromotionAttestRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
var warnings = new List<string>();
_logger.LogDebug("Creating promotion attestation");
// Load predicate
PromotionPredicate? predicate = request.Predicate;
if (predicate == null && !string.IsNullOrWhiteSpace(request.PredicatePath))
{
if (!File.Exists(request.PredicatePath))
{
errors.Add($"Predicate file not found: {request.PredicatePath}");
return new PromotionAttestResult
{
Success = false,
Errors = errors
};
}
try
{
var json = await File.ReadAllTextAsync(request.PredicatePath, cancellationToken).ConfigureAwait(false);
predicate = JsonSerializer.Deserialize<PromotionPredicate>(json, SerializerOptions);
}
catch (Exception ex)
{
errors.Add($"Failed to parse predicate: {ex.Message}");
return new PromotionAttestResult
{
Success = false,
Errors = errors
};
}
}
if (predicate == null)
{
errors.Add("No predicate provided. Use --predicate or provide assembled predicate JSON.");
return new PromotionAttestResult
{
Success = false,
Errors = errors
};
}
// Create in-toto statement
var statement = new
{
_type = "https://in-toto.io/Statement/v0.1",
predicateType = predicate.Type,
subject = predicate.Subject.Select(s => new
{
name = s.Name,
digest = s.Digest
}).ToArray(),
predicate
};
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
// Try to sign using cosign or Signer API
DsseEnvelope? envelope = null;
PromotionRekorEntry? rekorEntry = null;
string? bundlePath = null;
string? auditId = null;
string? signerKeyId = null;
// Try cosign first
try
{
var (success, envResult, rekor, keyId) = await SignWithCosignAsync(
statementJson,
request.KeyId,
request.UseKeyless,
request.UploadToRekor,
request.OutputPath,
cancellationToken).ConfigureAwait(false);
if (success)
{
envelope = envResult;
rekorEntry = rekor;
signerKeyId = keyId;
bundlePath = request.OutputPath;
}
else
{
warnings.Add("cosign signing failed; trying Signer API");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "cosign not available");
warnings.Add($"cosign not available: {ex.Message}");
}
// If cosign failed, try Signer API
if (envelope == null)
{
try
{
var (success, envResult, audit, keyId) = await SignWithSignerApiAsync(
statementJson,
request.Tenant,
request.KeyId,
cancellationToken).ConfigureAwait(false);
if (success)
{
envelope = envResult;
auditId = audit;
signerKeyId = keyId;
// Write bundle if output path specified
if (!string.IsNullOrWhiteSpace(request.OutputPath) && envelope != null)
{
var bundleJson = JsonSerializer.Serialize(envelope, SerializerOptions);
await File.WriteAllTextAsync(request.OutputPath, bundleJson, cancellationToken).ConfigureAwait(false);
bundlePath = request.OutputPath;
_logger.LogInformation("Wrote DSSE bundle to {Path}", bundlePath);
}
}
else
{
errors.Add("Signer API signing failed");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Signer API failed");
errors.Add($"Signer API failed: {ex.Message}");
}
}
if (envelope == null)
{
errors.Add("Failed to sign attestation. Ensure cosign is available or Signer API is configured.");
return new PromotionAttestResult
{
Success = false,
Errors = errors,
Warnings = warnings
};
}
return new PromotionAttestResult
{
Success = true,
BundlePath = bundlePath,
DsseEnvelope = envelope,
RekorEntry = rekorEntry,
AuditId = auditId,
SignerKeyId = signerKeyId,
SignedAt = _timeProvider.GetUtcNow(),
Warnings = warnings
};
}
private async Task<(bool success, DsseEnvelope? envelope, PromotionRekorEntry? rekor, string? keyId)> SignWithCosignAsync(
string statementJson,
string? keyId,
bool useKeyless,
bool uploadToRekor,
string? outputPath,
CancellationToken cancellationToken)
{
// Write statement to temp file
var tempStatement = Path.GetTempFileName();
var tempBundle = outputPath ?? Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempStatement, statementJson, cancellationToken).ConfigureAwait(false);
var args = new StringBuilder();
args.Append($"attest-blob --predicate \"{tempStatement}\" ");
args.Append("--type stella.ops/promotion ");
if (useKeyless)
{
args.Append("--yes ");
}
else if (!string.IsNullOrWhiteSpace(keyId))
{
args.Append($"--key \"{keyId}\" ");
}
if (!uploadToRekor)
{
args.Append("--no-tlog-upload ");
}
args.Append($"--bundle \"{tempBundle}\" ");
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "cosign",
Arguments = args.ToString(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
_logger.LogDebug("Executing: cosign {Args}", args);
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
_logger.LogWarning("cosign failed: {Stderr}", stderr);
return (false, null, null, null);
}
// Parse bundle file
if (File.Exists(tempBundle))
{
var bundleJson = await File.ReadAllTextAsync(tempBundle, cancellationToken).ConfigureAwait(false);
var bundle = JsonSerializer.Deserialize<JsonElement>(bundleJson);
// Extract envelope
var envelope = new DsseEnvelope
{
PayloadType = bundle.TryGetProperty("dsseEnvelope", out var env) && env.TryGetProperty("payloadType", out var pt)
? pt.GetString() ?? "application/vnd.in-toto+json"
: "application/vnd.in-toto+json",
Payload = env.TryGetProperty("payload", out var payload) ? payload.GetString() ?? "" : "",
Signatures = env.TryGetProperty("signatures", out var sigs)
? sigs.EnumerateArray().Select(s => new DsseSignature
{
KeyId = s.TryGetProperty("keyid", out var kid) ? kid.GetString() ?? "" : "",
Sig = s.TryGetProperty("sig", out var sig) ? sig.GetString() ?? "" : "",
Cert = s.TryGetProperty("cert", out var cert) ? cert.GetString() : null
}).ToArray()
: Array.Empty<DsseSignature>()
};
// Extract Rekor entry if present
PromotionRekorEntry? rekor = null;
if (bundle.TryGetProperty("rekorBundle", out var rekorBundle) ||
bundle.TryGetProperty("tlogEntries", out rekorBundle))
{
// Parse Rekor entry from bundle
if (rekorBundle.ValueKind == JsonValueKind.Array && rekorBundle.GetArrayLength() > 0)
{
var entry = rekorBundle[0];
rekor = new PromotionRekorEntry
{
Uuid = entry.TryGetProperty("logId", out var logId) ? logId.GetString() ?? "" : "",
LogIndex = entry.TryGetProperty("logIndex", out var idx) ? idx.GetInt64() : 0
};
}
}
return (true, envelope, rekor, keyId ?? "keyless");
}
return (false, null, null, null);
}
finally
{
if (File.Exists(tempStatement))
File.Delete(tempStatement);
if (string.IsNullOrWhiteSpace(outputPath) && File.Exists(tempBundle))
File.Delete(tempBundle);
}
}
private async Task<(bool success, DsseEnvelope? envelope, string? auditId, string? keyId)> SignWithSignerApiAsync(
string statementJson,
string tenant,
string? keyId,
CancellationToken cancellationToken)
{
// POST to Signer API
var request = new
{
tenant,
predicateType = "stella.ops/promotion@v1",
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson)),
keyId
};
var content = new StringContent(
JsonSerializer.Serialize(request, SerializerOptions),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync("/api/v1/signer/sign/dsse", content, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Signer API returned {Status}", response.StatusCode);
return (false, null, null, null);
}
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<JsonElement>(responseJson, SerializerOptions);
var envelope = new DsseEnvelope
{
PayloadType = result.TryGetProperty("payloadType", out var pt) ? pt.GetString() ?? "" : "application/vnd.in-toto+json",
Payload = result.TryGetProperty("payload", out var payload) ? payload.GetString() ?? "" : "",
Signatures = result.TryGetProperty("signatures", out var sigs)
? sigs.EnumerateArray().Select(s => new DsseSignature
{
KeyId = s.TryGetProperty("keyid", out var kid) ? kid.GetString() ?? "" : "",
Sig = s.TryGetProperty("sig", out var sig) ? sig.GetString() ?? "" : "",
Cert = s.TryGetProperty("cert", out var cert) ? cert.GetString() : null
}).ToArray()
: Array.Empty<DsseSignature>()
};
var auditId = result.TryGetProperty("auditId", out var aid) ? aid.GetString() : null;
var usedKeyId = result.TryGetProperty("keyId", out var kid2) ? kid2.GetString() : keyId;
return (true, envelope, auditId, usedKeyId);
}
// CLI-PROMO-70-002: Verify implementation
public async Task<PromotionVerifyResult> VerifyAsync(
PromotionVerifyRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
var warnings = new List<string>();
_logger.LogDebug("Verifying promotion attestation");
// Load DSSE bundle
DsseEnvelope? envelope = null;
if (!string.IsNullOrWhiteSpace(request.BundlePath))
{
if (!File.Exists(request.BundlePath))
{
errors.Add($"Bundle file not found: {request.BundlePath}");
return new PromotionVerifyResult
{
Success = false,
Errors = errors
};
}
try
{
var json = await File.ReadAllTextAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
var bundleDoc = JsonSerializer.Deserialize<JsonElement>(json, SerializerOptions);
// Try to extract DSSE envelope from various bundle formats
if (bundleDoc.TryGetProperty("dsseEnvelope", out var envProp))
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envProp.GetRawText(), SerializerOptions);
}
else if (bundleDoc.TryGetProperty("payloadType", out _))
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, SerializerOptions);
}
}
catch (Exception ex)
{
errors.Add($"Failed to parse bundle: {ex.Message}");
return new PromotionVerifyResult
{
Success = false,
Errors = errors
};
}
}
if (envelope == null)
{
errors.Add("No DSSE bundle provided. Use --bundle to specify the attestation bundle.");
return new PromotionVerifyResult
{
Success = false,
Errors = errors
};
}
// Decode and parse predicate
PromotionPredicate? predicate = null;
try
{
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var statement = JsonSerializer.Deserialize<JsonElement>(payloadJson, SerializerOptions);
if (statement.TryGetProperty("predicate", out var pred))
{
predicate = JsonSerializer.Deserialize<PromotionPredicate>(pred.GetRawText(), SerializerOptions);
}
}
catch (Exception ex)
{
errors.Add($"Failed to decode payload: {ex.Message}");
}
// Verify signature
PromotionSignatureVerification? sigVerification = null;
if (!request.SkipSignatureVerification)
{
sigVerification = await VerifySignatureAsync(envelope, request.TrustRootPath, cancellationToken).ConfigureAwait(false);
if (!sigVerification.Verified)
{
warnings.Add($"Signature verification failed: {sigVerification.Error}");
}
}
else
{
warnings.Add("Signature verification skipped");
sigVerification = new PromotionSignatureVerification { Verified = true };
}
// Verify materials
PromotionMaterialVerification? materialVerification = null;
if (predicate != null)
{
materialVerification = await VerifyMaterialsAsync(predicate, request.SbomPath, request.VexPath, cancellationToken).ConfigureAwait(false);
if (!materialVerification.Verified)
{
var failedMaterials = materialVerification.Materials.Where(m => !m.Verified).Select(m => m.Role);
warnings.Add($"Material verification failed for: {string.Join(", ", failedMaterials)}");
}
}
// Verify Rekor
PromotionRekorVerification? rekorVerification = null;
if (!request.SkipRekorVerification && predicate?.Rekor != null)
{
rekorVerification = await VerifyRekorAsync(predicate.Rekor, request.CheckpointPath, cancellationToken).ConfigureAwait(false);
if (!rekorVerification.Verified)
{
warnings.Add($"Rekor verification failed: {rekorVerification.Error}");
}
}
else if (request.SkipRekorVerification)
{
warnings.Add("Rekor verification skipped");
rekorVerification = new PromotionRekorVerification { Verified = true };
}
var verified = (sigVerification?.Verified ?? false) &&
(materialVerification?.Verified ?? true) &&
(rekorVerification?.Verified ?? true);
return new PromotionVerifyResult
{
Success = errors.Count == 0,
Verified = verified,
SignatureVerification = sigVerification,
MaterialVerification = materialVerification,
RekorVerification = rekorVerification,
Predicate = predicate,
Errors = errors,
Warnings = warnings
};
}
private async Task<PromotionSignatureVerification> VerifySignatureAsync(
DsseEnvelope envelope,
string? trustRootPath,
CancellationToken cancellationToken)
{
if (envelope.Signatures.Count == 0)
{
return new PromotionSignatureVerification
{
Verified = false,
Error = "No signatures present in envelope"
};
}
var sig = envelope.Signatures[0];
// If certificate is present, verify with certificate chain
if (!string.IsNullOrWhiteSpace(sig.Cert))
{
try
{
var certBytes = Convert.FromBase64String(sig.Cert);
using var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadCertificate(certBytes);
// Build PAE for verification
var pae = BuildPae(envelope.PayloadType, envelope.Payload);
var sigBytes = Convert.FromBase64String(sig.Sig);
// Get public key and verify
using var rsa = cert.GetRSAPublicKey();
using var ecdsa = cert.GetECDsaPublicKey();
bool verified = false;
string? algorithm = null;
if (ecdsa != null)
{
verified = ecdsa.VerifyData(pae, sigBytes, HashAlgorithmName.SHA256);
algorithm = "ECDSA-P256";
}
else if (rsa != null)
{
verified = rsa.VerifyData(pae, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
algorithm = "RSA-PKCS1";
}
return new PromotionSignatureVerification
{
Verified = verified,
KeyId = sig.KeyId,
Algorithm = algorithm,
CertSubject = cert.Subject,
CertIssuer = cert.Issuer,
ValidFrom = cert.NotBefore,
ValidTo = cert.NotAfter,
Error = verified ? null : "Signature verification failed"
};
}
catch (Exception ex)
{
return new PromotionSignatureVerification
{
Verified = false,
KeyId = sig.KeyId,
Error = $"Certificate verification error: {ex.Message}"
};
}
}
// Try using cosign verify-blob
try
{
var tempBundle = Path.GetTempFileName();
var tempPayload = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempBundle, JsonSerializer.Serialize(envelope, SerializerOptions), cancellationToken).ConfigureAwait(false);
var payloadBytes = Convert.FromBase64String(envelope.Payload);
await File.WriteAllBytesAsync(tempPayload, payloadBytes, cancellationToken).ConfigureAwait(false);
var args = $"verify-blob --bundle \"{tempBundle}\" \"{tempPayload}\"";
if (!string.IsNullOrWhiteSpace(trustRootPath))
{
args += $" --certificate-chain \"{trustRootPath}\"";
}
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "cosign",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
return new PromotionSignatureVerification
{
Verified = process.ExitCode == 0,
KeyId = sig.KeyId,
Algorithm = "cosign",
Error = process.ExitCode != 0 ? "cosign verification failed" : null
};
}
finally
{
if (File.Exists(tempBundle)) File.Delete(tempBundle);
if (File.Exists(tempPayload)) File.Delete(tempPayload);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "cosign verify not available");
}
return new PromotionSignatureVerification
{
Verified = false,
KeyId = sig.KeyId,
Error = "Unable to verify signature; cosign not available and no certificate in bundle"
};
}
private static byte[] BuildPae(string payloadType, string payload)
{
// Pre-Authentication Encoding (PAE)
// PAE(type, body) = "DSSEv1" || SP || LEN(type) || SP || type || SP || LEN(body) || SP || body
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var bodyBytes = Convert.FromBase64String(payload);
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
writer.Write(BitConverter.GetBytes((long)typeBytes.Length));
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write(typeBytes);
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write(BitConverter.GetBytes((long)bodyBytes.Length));
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write(bodyBytes);
return ms.ToArray();
}
private async Task<PromotionMaterialVerification> VerifyMaterialsAsync(
PromotionPredicate predicate,
string? sbomPath,
string? vexPath,
CancellationToken cancellationToken)
{
var entries = new List<PromotionMaterialVerificationEntry>();
var allVerified = true;
foreach (var material in predicate.Materials)
{
string? filePath = material.Role.ToLowerInvariant() switch
{
"sbom" => sbomPath,
"vex" => vexPath,
_ => null
};
if (string.IsNullOrWhiteSpace(filePath))
{
entries.Add(new PromotionMaterialVerificationEntry
{
Role = material.Role,
Verified = true, // Skip if not provided
ExpectedDigest = material.Digest
});
continue;
}
if (!File.Exists(filePath))
{
entries.Add(new PromotionMaterialVerificationEntry
{
Role = material.Role,
Verified = false,
ExpectedDigest = material.Digest,
Error = $"File not found: {filePath}"
});
allVerified = false;
continue;
}
var actualDigest = await ComputeFileDigestAsync(filePath, cancellationToken).ConfigureAwait(false);
var verified = string.Equals(actualDigest, material.Digest, StringComparison.OrdinalIgnoreCase);
entries.Add(new PromotionMaterialVerificationEntry
{
Role = material.Role,
Verified = verified,
ExpectedDigest = material.Digest,
ActualDigest = actualDigest,
Error = verified ? null : "Digest mismatch"
});
if (!verified)
{
allVerified = false;
}
}
return new PromotionMaterialVerification
{
Verified = allVerified,
Materials = entries
};
}
private Task<PromotionRekorVerification> VerifyRekorAsync(
PromotionRekorEntry rekorEntry,
string? checkpointPath,
CancellationToken cancellationToken)
{
// For offline verification, we verify the inclusion proof
var proof = rekorEntry.InclusionProof;
if (proof == null)
{
return Task.FromResult(new PromotionRekorVerification
{
Verified = false,
Uuid = rekorEntry.Uuid,
LogIndex = rekorEntry.LogIndex,
Error = "No inclusion proof present"
});
}
// Verify Merkle inclusion proof
bool inclusionVerified = VerifyMerkleInclusion(
rekorEntry.LogIndex,
proof.TreeSize,
proof.RootHash,
proof.Hashes);
// Verify checkpoint if provided
bool checkpointVerified = true;
if (!string.IsNullOrWhiteSpace(checkpointPath) && proof.Checkpoint != null)
{
// For now, just verify the checkpoint hash matches
checkpointVerified = string.Equals(proof.Checkpoint.Hash, proof.RootHash, StringComparison.OrdinalIgnoreCase);
}
return Task.FromResult(new PromotionRekorVerification
{
Verified = inclusionVerified && checkpointVerified,
Uuid = rekorEntry.Uuid,
LogIndex = rekorEntry.LogIndex,
InclusionProofVerified = inclusionVerified,
CheckpointVerified = checkpointVerified,
Error = !inclusionVerified ? "Inclusion proof verification failed" :
!checkpointVerified ? "Checkpoint verification failed" : null
});
}
private static bool VerifyMerkleInclusion(long logIndex, long treeSize, string rootHash, IReadOnlyList<string> hashes)
{
// Simplified Merkle inclusion verification
// In production, this would implement the full RFC 6962 verification
if (string.IsNullOrWhiteSpace(rootHash) || hashes.Count == 0)
{
return false;
}
// For now, verify basic structure
// Full implementation would recompute root from leaf and proof path
return logIndex >= 0 && logIndex < treeSize && hashes.All(h => !string.IsNullOrWhiteSpace(h));
}
[GeneratedRegex(@"sha256:([a-f0-9]{64})", RegexOptions.IgnoreCase)]
private static partial Regex DigestRegex();
}