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; /// /// Assembler for promotion attestations. /// Per CLI-PROMO-70-001. /// 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 _logger; public PromotionAssembler(HttpClient httpClient, ICryptoHash cryptoHash, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task AssembleAsync( PromotionAssembleRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var errors = new List(); var warnings = new List(); var materials = new List(); _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 { ["sha256"] = imageDigest } } }, Materials = materials, Promotion = new PromotionMetadata { From = request.FromEnvironment, To = request.ToEnvironment, Actor = request.Actor ?? Environment.UserName, Timestamp = DateTimeOffset.UtcNow, 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 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 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 AttestAsync( PromotionAttestRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var errors = new List(); var warnings = new List(); _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(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 = DateTimeOffset.UtcNow, 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(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() }; // 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(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() }; 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 VerifyAsync( PromotionVerifyRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var errors = new List(); var warnings = new List(); _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(json, SerializerOptions); // Try to extract DSSE envelope from various bundle formats if (bundleDoc.TryGetProperty("dsseEnvelope", out var envProp)) { envelope = JsonSerializer.Deserialize(envProp.GetRawText(), SerializerOptions); } else if (bundleDoc.TryGetProperty("payloadType", out _)) { envelope = JsonSerializer.Deserialize(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(payloadJson, SerializerOptions); if (statement.TryGetProperty("predicate", out var pred)) { predicate = JsonSerializer.Deserialize(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 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 = new System.Security.Cryptography.X509Certificates.X509Certificate2(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 VerifyMaterialsAsync( PromotionPredicate predicate, string? sbomPath, string? vexPath, CancellationToken cancellationToken) { var entries = new List(); 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 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 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(); }