using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.AirGap.Importer.Contracts; using StellaOps.AirGap.Importer.Policy; using StellaOps.AirGap.Importer.Reconciliation; using StellaOps.AirGap.Importer.Reconciliation.Parsers; using StellaOps.Cli.Telemetry; using Spectre.Console; namespace StellaOps.Cli.Commands; internal static partial class CommandHandlers { public static async Task HandleVerifyOfflineAsync( IServiceProvider services, string evidenceDirectory, string artifactDigest, string policyPath, string? outputDirectory, string outputFormat, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("verify-offline"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.verify.offline", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("verify offline"); var emitJson = string.Equals(outputFormat, "json", StringComparison.OrdinalIgnoreCase); try { if (string.IsNullOrWhiteSpace(evidenceDirectory)) { await WriteVerifyOfflineErrorAsync(emitJson, "--evidence-dir is required.", OfflineExitCodes.ValidationFailed, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.ValidationFailed; return; } evidenceDirectory = Path.GetFullPath(evidenceDirectory); if (!Directory.Exists(evidenceDirectory)) { await WriteVerifyOfflineErrorAsync(emitJson, $"Evidence directory not found: {evidenceDirectory}", OfflineExitCodes.FileNotFound, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.FileNotFound; return; } string normalizedArtifact; try { normalizedArtifact = ArtifactIndex.NormalizeDigest(artifactDigest); } catch (Exception ex) { await WriteVerifyOfflineErrorAsync(emitJson, $"Invalid --artifact: {ex.Message}", OfflineExitCodes.ValidationFailed, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.ValidationFailed; return; } var resolvedPolicyPath = ResolvePolicyPath(evidenceDirectory, policyPath); if (resolvedPolicyPath is null) { await WriteVerifyOfflineErrorAsync(emitJson, $"Policy file not found: {policyPath}", OfflineExitCodes.FileNotFound, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.FileNotFound; return; } OfflineVerificationPolicy policy; try { policy = await OfflineVerificationPolicyLoader.LoadAsync(resolvedPolicyPath, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await WriteVerifyOfflineErrorAsync(emitJson, $"Failed to load policy: {ex.Message}", OfflineExitCodes.PolicyLoadFailed, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.PolicyLoadFailed; return; } var violations = new List(); if (policy.Keys.Count == 0) { violations.Add(new VerifyOfflineViolation("policy.keys.missing", "Policy 'keys' must contain at least one trust-root public key path.")); } var trustRootFiles = policy.Keys .Select(key => ResolveEvidencePath(evidenceDirectory, key)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) .ToList(); var trustRoots = await TryBuildTrustRootsAsync(evidenceDirectory, trustRootFiles, violations, cancellationToken) .ConfigureAwait(false); var verifyRekor = string.Equals(policy.Tlog?.Mode, "offline", StringComparison.OrdinalIgnoreCase); var rekorPublicKeyPath = verifyRekor ? ResolveRekorPublicKeyPath(evidenceDirectory) : null; if (verifyRekor && rekorPublicKeyPath is null) { violations.Add(new VerifyOfflineViolation( "policy.tlog.rekor_key.missing", "Policy requires offline tlog verification, but Rekor public key was not found (expected under evidence/keys/tlog-root/rekor-pub.pem).")); } var outputRoot = string.IsNullOrWhiteSpace(outputDirectory) ? Path.Combine(Environment.CurrentDirectory, ".stellaops", "verify-offline") : Path.GetFullPath(outputDirectory); var outputDir = Path.Combine(outputRoot, normalizedArtifact.Replace(':', '_')); var reconciler = new EvidenceReconciler(); EvidenceGraph graph; try { graph = await reconciler.ReconcileAsync( evidenceDirectory, outputDir, new ReconciliationOptions { VerifySignatures = true, VerifyRekorProofs = verifyRekor, TrustRoots = trustRoots, RekorPublicKeyPath = rekorPublicKeyPath }, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { await WriteVerifyOfflineErrorAsync(emitJson, $"Evidence reconciliation failed: {ex.Message}", OfflineExitCodes.VerificationFailed, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.VerificationFailed; return; } var artifactNode = graph.Nodes.FirstOrDefault(node => string.Equals(node.Id, normalizedArtifact, StringComparison.Ordinal)); if (artifactNode is null) { violations.Add(new VerifyOfflineViolation("artifact.not_found", $"Artifact not found in evidence set: {normalizedArtifact}")); } else { ApplyPolicyChecks(policy, artifactNode, verifyRekor, violations); } var graphSerializer = new EvidenceGraphSerializer(); var graphHash = graphSerializer.ComputeHash(graph); var attestationsFound = artifactNode?.Attestations?.Count ?? 0; var attestationsVerified = artifactNode?.Attestations? .Count(att => att.SignatureValid && (!verifyRekor || att.RekorVerified)) ?? 0; var sbomsFound = artifactNode?.Sboms?.Count ?? 0; var passed = violations.Count == 0; var exitCode = passed ? OfflineExitCodes.Success : OfflineExitCodes.VerificationFailed; await WriteVerifyOfflineResultAsync( emitJson, new VerifyOfflineResultPayload( Status: passed ? "passed" : "failed", ExitCode: exitCode, Artifact: normalizedArtifact, EvidenceDir: evidenceDirectory, PolicyPath: resolvedPolicyPath, OutputDir: outputDir, EvidenceGraphHash: graphHash, SbomsFound: sbomsFound, AttestationsFound: attestationsFound, AttestationsVerified: attestationsVerified, Violations: violations), cancellationToken) .ConfigureAwait(false); Environment.ExitCode = exitCode; } catch (OperationCanceledException) { await WriteVerifyOfflineErrorAsync(emitJson, "Cancelled.", OfflineExitCodes.Cancelled, cancellationToken) .ConfigureAwait(false); Environment.ExitCode = OfflineExitCodes.Cancelled; } finally { verbosity.MinimumLevel = previousLevel; } } private static void ApplyPolicyChecks( OfflineVerificationPolicy policy, EvidenceNode node, bool verifyRekor, List violations) { var subjectAlg = policy.Constraints?.Subjects?.Algorithm; if (!string.IsNullOrWhiteSpace(subjectAlg) && !string.Equals(subjectAlg, "sha256", StringComparison.OrdinalIgnoreCase)) { violations.Add(new VerifyOfflineViolation("policy.subjects.alg.unsupported", $"Unsupported subjects.alg '{subjectAlg}'. Only sha256 is supported.")); } var attestations = node.Attestations ?? Array.Empty(); foreach (var attestation in attestations.OrderBy(static att => att.PredicateType, StringComparer.Ordinal)) { if (!attestation.SignatureValid) { violations.Add(new VerifyOfflineViolation( "attestation.signature.invalid", $"DSSE signature not verified for predicateType '{attestation.PredicateType}' (path: {attestation.Path}).")); } if (verifyRekor && !attestation.RekorVerified) { violations.Add(new VerifyOfflineViolation( "attestation.rekor.invalid", $"Rekor inclusion proof not verified for predicateType '{attestation.PredicateType}' (path: {attestation.Path}).")); } } var required = policy.Attestations?.Required ?? Array.Empty(); foreach (var requirement in required.OrderBy(static req => req.Type ?? string.Empty, StringComparer.Ordinal)) { if (string.IsNullOrWhiteSpace(requirement.Type)) { continue; } if (IsRequirementSatisfied(requirement.Type, node, verifyRekor)) { continue; } violations.Add(new VerifyOfflineViolation( "policy.attestations.required.missing", $"Required evidence missing or unverified: {requirement.Type}")); } } private static bool IsRequirementSatisfied(string requirementType, EvidenceNode node, bool verifyRekor) { requirementType = requirementType.Trim().ToLowerInvariant(); var attestations = node.Attestations ?? Array.Empty(); var sboms = node.Sboms ?? Array.Empty(); bool Verified(AttestationNodeRef att) => att.SignatureValid && (!verifyRekor || att.RekorVerified); if (requirementType is "slsa-provenance" or "slsa") { return attestations.Any(att => Verified(att) && IsSlsaProvenance(att.PredicateType)); } if (requirementType is "cyclonedx-sbom" or "cyclonedx") { return sboms.Any(sbom => string.Equals(sbom.Format, SbomFormat.CycloneDx.ToString(), StringComparison.OrdinalIgnoreCase)) || attestations.Any(att => Verified(att) && string.Equals(att.PredicateType, PredicateTypes.CycloneDx, StringComparison.OrdinalIgnoreCase)); } if (requirementType is "spdx-sbom" or "spdx") { return sboms.Any(sbom => string.Equals(sbom.Format, SbomFormat.Spdx.ToString(), StringComparison.OrdinalIgnoreCase)) || attestations.Any(att => Verified(att) && string.Equals(att.PredicateType, PredicateTypes.Spdx, StringComparison.OrdinalIgnoreCase)); } if (requirementType is "vex") { return attestations.Any(att => Verified(att) && (string.Equals(att.PredicateType, PredicateTypes.OpenVex, StringComparison.OrdinalIgnoreCase) || string.Equals(att.PredicateType, PredicateTypes.Csaf, StringComparison.OrdinalIgnoreCase))); } if (requirementType.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || requirementType.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return attestations.Any(att => Verified(att) && string.Equals(att.PredicateType, requirementType, StringComparison.OrdinalIgnoreCase)); } return attestations.Any(att => Verified(att) && att.PredicateType.Contains(requirementType, StringComparison.OrdinalIgnoreCase)); } private static bool IsSlsaProvenance(string predicateType) { if (string.IsNullOrWhiteSpace(predicateType)) { return false; } return string.Equals(predicateType, PredicateTypes.SlsaProvenanceV1, StringComparison.OrdinalIgnoreCase) || string.Equals(predicateType, PredicateTypes.SlsaProvenanceV02, StringComparison.OrdinalIgnoreCase) || predicateType.Contains("slsa.dev/provenance", StringComparison.OrdinalIgnoreCase); } private static string? ResolvePolicyPath(string evidenceDir, string input) { if (string.IsNullOrWhiteSpace(input)) { return null; } var trimmed = input.Trim(); if (Path.IsPathRooted(trimmed)) { var full = Path.GetFullPath(trimmed); return File.Exists(full) ? full : null; } var candidate1 = Path.GetFullPath(Path.Combine(evidenceDir, trimmed)); if (File.Exists(candidate1)) { return candidate1; } var candidate2 = Path.GetFullPath(Path.Combine(evidenceDir, "policy", trimmed)); if (File.Exists(candidate2)) { return candidate2; } var candidate3 = Path.GetFullPath(trimmed); return File.Exists(candidate3) ? candidate3 : null; } private static string ResolveEvidencePath(string evidenceDir, string raw) { raw = raw.Trim(); if (Path.IsPathRooted(raw)) { return Path.GetFullPath(raw); } var normalized = raw.Replace('\\', '/'); if (normalized.StartsWith("./", StringComparison.Ordinal)) { normalized = normalized[2..]; } if (normalized.StartsWith("evidence/", StringComparison.OrdinalIgnoreCase)) { normalized = normalized["evidence/".Length..]; } var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); return Path.GetFullPath(Path.Combine(new[] { evidenceDir }.Concat(segments).ToArray())); } private static string? ResolveRekorPublicKeyPath(string evidenceDir) { var candidates = new[] { Path.Combine(evidenceDir, "keys", "tlog-root", "rekor-pub.pem"), Path.Combine(evidenceDir, "tlog", "rekor-pub.pem"), Path.Combine(evidenceDir, "rekor-pub.pem") }; foreach (var candidate in candidates) { if (File.Exists(candidate)) { return candidate; } } return null; } private static async Task TryBuildTrustRootsAsync( string evidenceDir, IReadOnlyList keyFiles, List violations, CancellationToken ct) { if (keyFiles.Count == 0) { return null; } var publicKeys = new Dictionary(StringComparer.Ordinal); var fingerprints = new HashSet(StringComparer.Ordinal); foreach (var keyFile in keyFiles) { if (!File.Exists(keyFile)) { violations.Add(new VerifyOfflineViolation("policy.keys.missing_file", $"Trust-root public key not found: {keyFile}")); continue; } try { var keyBytes = await LoadPublicKeyDerBytesAsync(keyFile, ct).ConfigureAwait(false); var fingerprint = ComputeKeyFingerprint(keyBytes); publicKeys[fingerprint] = keyBytes; fingerprints.Add(fingerprint); } catch (Exception ex) { violations.Add(new VerifyOfflineViolation("policy.keys.load_failed", $"Failed to load trust-root key '{keyFile}': {ex.Message}")); } } if (publicKeys.Count == 0) { return null; } return new TrustRootConfig( RootBundlePath: evidenceDir, TrustedKeyFingerprints: fingerprints.ToArray(), AllowedSignatureAlgorithms: new[] { "rsassa-pss-sha256" }, NotBeforeUtc: null, NotAfterUtc: null, PublicKeys: publicKeys); } private static async Task LoadPublicKeyDerBytesAsync(string path, CancellationToken ct) { var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); var text = Encoding.UTF8.GetString(bytes); const string Begin = "-----BEGIN PUBLIC KEY-----"; const string End = "-----END PUBLIC KEY-----"; var begin = text.IndexOf(Begin, StringComparison.Ordinal); var end = text.IndexOf(End, StringComparison.Ordinal); if (begin >= 0 && end > begin) { var base64 = text .Substring(begin + Begin.Length, end - (begin + Begin.Length)) .Replace("\r", string.Empty, StringComparison.Ordinal) .Replace("\n", string.Empty, StringComparison.Ordinal) .Trim(); return Convert.FromBase64String(base64); } // Allow raw base64 (SPKI). var trimmed = text.Trim(); try { return Convert.FromBase64String(trimmed); } catch { throw new InvalidDataException("Unsupported public key format (expected PEM or raw base64 SPKI)."); } } private static Task WriteVerifyOfflineErrorAsync( bool emitJson, string message, int exitCode, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (emitJson) { var json = JsonSerializer.Serialize(new { status = "error", exitCode, message }, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); AnsiConsole.Console.WriteLine(json); return Task.CompletedTask; } AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); return Task.CompletedTask; } private static Task WriteVerifyOfflineResultAsync( bool emitJson, VerifyOfflineResultPayload payload, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (emitJson) { var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); AnsiConsole.Console.WriteLine(json); return Task.CompletedTask; } var headline = payload.Status switch { "passed" => "[green]Verification PASSED[/]", "failed" => "[red]Verification FAILED[/]", _ => "[yellow]Verification result unknown[/]" }; AnsiConsole.MarkupLine(headline); AnsiConsole.WriteLine(); var table = new Table().AddColumns("Field", "Value"); table.AddRow("Artifact", Markup.Escape(payload.Artifact)); table.AddRow("Evidence dir", Markup.Escape(payload.EvidenceDir)); table.AddRow("Policy", Markup.Escape(payload.PolicyPath)); table.AddRow("Output dir", Markup.Escape(payload.OutputDir)); table.AddRow("Evidence graph hash", Markup.Escape(payload.EvidenceGraphHash)); table.AddRow("SBOMs found", payload.SbomsFound.ToString()); table.AddRow("Attestations found", payload.AttestationsFound.ToString()); table.AddRow("Attestations verified", payload.AttestationsVerified.ToString()); AnsiConsole.Write(table); if (payload.Violations.Count > 0) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[red]Violations:[/]"); foreach (var violation in payload.Violations.OrderBy(static violation => violation.Rule, StringComparer.Ordinal)) { AnsiConsole.MarkupLine($" - {Markup.Escape(violation.Rule)}: {Markup.Escape(violation.Message)}"); } } return Task.CompletedTask; } private sealed record VerifyOfflineViolation(string Rule, string Message); private sealed record VerifyOfflineResultPayload( string Status, int ExitCode, string Artifact, string EvidenceDir, string PolicyPath, string OutputDir, string EvidenceGraphHash, int SbomsFound, int AttestationsFound, int AttestationsVerified, IReadOnlyList Violations); }