1128 lines
40 KiB
C#
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();
|
|
}
|