using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Cli.Output; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; /// /// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody. /// Per CLI-FORENSICS-54-001. /// internal sealed class ForensicVerifier : IForensicVerifier { private const string PaePrefix = "DSSEv1"; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; private readonly ILogger _logger; public ForensicVerifier(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task VerifyBundleAsync( string bundlePath, ForensicVerificationOptions options, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath); ArgumentNullException.ThrowIfNull(options); var errors = new List(); var warnings = new List(); var verifiedAt = DateTimeOffset.UtcNow; _logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath); // Check bundle exists if (!File.Exists(bundlePath) && !Directory.Exists(bundlePath)) { errors.Add(new ForensicVerificationError { Code = CliErrorCodes.ForensicBundleNotFound, Message = "Bundle path not found", Detail = bundlePath }); return new ForensicVerificationResult { BundlePath = bundlePath, IsValid = false, VerifiedAt = verifiedAt, Errors = errors }; } // Load manifest var manifestPath = ResolveManifestPath(bundlePath); if (manifestPath is null || !File.Exists(manifestPath)) { errors.Add(new ForensicVerificationError { Code = CliErrorCodes.ForensicBundleInvalid, Message = "Manifest not found in bundle", Detail = "Expected manifest.json in bundle root" }); return new ForensicVerificationResult { BundlePath = bundlePath, IsValid = false, VerifiedAt = verifiedAt, Errors = errors }; } ForensicSnapshotManifest manifest; try { var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); manifest = JsonSerializer.Deserialize(manifestJson, SerializerOptions) ?? throw new InvalidDataException("Invalid manifest JSON"); } catch (Exception ex) when (ex is JsonException or InvalidDataException) { _logger.LogError(ex, "Failed to parse manifest at {ManifestPath}", manifestPath); errors.Add(new ForensicVerificationError { Code = CliErrorCodes.ForensicBundleInvalid, Message = "Failed to parse manifest", Detail = ex.Message }); return new ForensicVerificationResult { BundlePath = bundlePath, IsValid = false, VerifiedAt = verifiedAt, Errors = errors }; } var bundleDir = Path.GetDirectoryName(manifestPath) ?? bundlePath; // Verify manifest var manifestVerification = await VerifyManifestAsync(manifest, manifestPath, cancellationToken) .ConfigureAwait(false); if (!manifestVerification.IsValid) { errors.Add(new ForensicVerificationError { Code = CliErrorCodes.ForensicChecksumMismatch, Message = "Manifest digest verification failed", Detail = $"Expected: {manifestVerification.Digest}, Computed: {manifestVerification.ComputedDigest}" }); } // Verify checksums ForensicChecksumVerification? checksumVerification = null; if (options.VerifyChecksums) { checksumVerification = await VerifyChecksumsAsync(manifest, bundleDir, cancellationToken) .ConfigureAwait(false); foreach (var failure in checksumVerification.FailedArtifacts) { errors.Add(new ForensicVerificationError { Code = CliErrorCodes.ForensicChecksumMismatch, Message = $"Checksum mismatch for artifact {failure.ArtifactId}", Detail = failure.Reason, ArtifactId = failure.ArtifactId }); } } // Verify signatures ForensicSignatureVerification? signatureVerification = null; if (options.VerifySignatures && manifest.Signature is not null) { var trustRoots = options.TrustRoots.ToList(); if (!string.IsNullOrWhiteSpace(options.TrustRootPath)) { var loadedRoots = await LoadTrustRootsAsync(options.TrustRootPath, cancellationToken) .ConfigureAwait(false); trustRoots.AddRange(loadedRoots); } if (trustRoots.Count == 0) { warnings.Add("No trust roots configured; signature verification skipped"); } else { signatureVerification = VerifySignature(manifest, trustRoots); if (!signatureVerification.IsValid) { var untrusted = signatureVerification.Signatures .Where(s => !s.IsTrusted) .Select(s => s.KeyId); errors.Add(new ForensicVerificationError { Code = signatureVerification.VerifiedSignatures == 0 ? CliErrorCodes.ForensicSignatureInvalid : CliErrorCodes.ForensicSignatureUntrusted, Message = "Signature verification failed", Detail = string.Join(", ", signatureVerification.Signatures.Select(s => s.Reason).Where(r => r is not null)) }); } } } // Verify chain of custody ForensicChainOfCustodyVerification? chainVerification = null; if (options.VerifyChainOfCustody && manifest.Metadata?.ChainOfCustody is { Count: > 0 }) { chainVerification = VerifyChainOfCustody(manifest.Metadata.ChainOfCustody, options.StrictTimeline); if (!chainVerification.IsValid) { var errorCode = !chainVerification.TimelineValid ? CliErrorCodes.ForensicTimelineInvalid : CliErrorCodes.ForensicChainOfCustodyBroken; errors.Add(new ForensicVerificationError { Code = errorCode, Message = "Chain of custody verification failed", Detail = chainVerification.Gaps.Count > 0 ? $"Found {chainVerification.Gaps.Count} timeline gap(s)" : "Invalid entry signatures" }); } } var isValid = errors.Count == 0 && manifestVerification.IsValid && (checksumVerification?.IsValid ?? true) && (signatureVerification?.IsValid ?? true) && (chainVerification?.IsValid ?? true); return new ForensicVerificationResult { BundlePath = bundlePath, IsValid = isValid, VerifiedAt = verifiedAt, ManifestVerification = manifestVerification, ChecksumVerification = checksumVerification, SignatureVerification = signatureVerification, ChainOfCustodyVerification = chainVerification, Errors = errors, Warnings = warnings }; } public async Task> LoadTrustRootsAsync( string trustRootPath, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath); if (!File.Exists(trustRootPath)) { _logger.LogWarning("Trust root file not found: {Path}", trustRootPath); return Array.Empty(); } try { var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false); // Try array format first var roots = JsonSerializer.Deserialize>(json, SerializerOptions); if (roots is not null) { return roots; } // Try single object var singleRoot = JsonSerializer.Deserialize(json, SerializerOptions); if (singleRoot is not null) { return new[] { singleRoot }; } return Array.Empty(); } catch (JsonException ex) { _logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath); return Array.Empty(); } } private static string? ResolveManifestPath(string bundlePath) { if (File.Exists(bundlePath)) { // If bundlePath is a file, check if it's the manifest var fileName = Path.GetFileName(bundlePath); if (fileName.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)) { return bundlePath; } // Otherwise look for manifest in same directory var dir = Path.GetDirectoryName(bundlePath); if (dir is not null) { var manifestInDir = Path.Combine(dir, "manifest.json"); if (File.Exists(manifestInDir)) { return manifestInDir; } } return null; } if (Directory.Exists(bundlePath)) { var manifestPath = Path.Combine(bundlePath, "manifest.json"); return File.Exists(manifestPath) ? manifestPath : null; } return null; } private async Task VerifyManifestAsync( ForensicSnapshotManifest manifest, string manifestPath, CancellationToken cancellationToken) { var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false); var computedDigest = ComputeDigest(manifestBytes, manifest.DigestAlgorithm); var isValid = string.Equals(manifest.Digest, computedDigest, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(manifest.Digest); // Allow empty digest for unsigned manifests return new ForensicManifestVerification { IsValid = isValid, ManifestId = manifest.ManifestId, Version = manifest.Version, Digest = manifest.Digest, DigestAlgorithm = manifest.DigestAlgorithm, ComputedDigest = computedDigest, ArtifactCount = manifest.Artifacts.Count }; } private async Task VerifyChecksumsAsync( ForensicSnapshotManifest manifest, string bundleDir, CancellationToken cancellationToken) { var failures = new List(); var verified = 0; foreach (var artifact in manifest.Artifacts) { var artifactPath = Path.Combine(bundleDir, artifact.Path); if (!File.Exists(artifactPath)) { failures.Add(new ForensicArtifactChecksumFailure { ArtifactId = artifact.ArtifactId, Path = artifact.Path, ExpectedDigest = artifact.Digest, ActualDigest = string.Empty, Reason = "Artifact file not found" }); continue; } try { var fileBytes = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false); var actualDigest = ComputeDigest(fileBytes, artifact.DigestAlgorithm); if (!string.Equals(artifact.Digest, actualDigest, StringComparison.OrdinalIgnoreCase)) { failures.Add(new ForensicArtifactChecksumFailure { ArtifactId = artifact.ArtifactId, Path = artifact.Path, ExpectedDigest = artifact.Digest, ActualDigest = actualDigest, Reason = "Digest mismatch" }); } else { verified++; } } catch (IOException ex) { failures.Add(new ForensicArtifactChecksumFailure { ArtifactId = artifact.ArtifactId, Path = artifact.Path, ExpectedDigest = artifact.Digest, ActualDigest = string.Empty, Reason = $"IO error: {ex.Message}" }); } } return new ForensicChecksumVerification { IsValid = failures.Count == 0, TotalArtifacts = manifest.Artifacts.Count, VerifiedArtifacts = verified, FailedArtifacts = failures }; } private ForensicSignatureVerification VerifySignature( ForensicSnapshotManifest manifest, IReadOnlyList trustRoots) { if (manifest.Signature is null) { return new ForensicSignatureVerification { IsValid = false, SignatureCount = 0, VerifiedSignatures = 0, Signatures = Array.Empty() }; } var signatures = new List(); var verifiedCount = 0; // Find matching trust root var matchingRoot = trustRoots.FirstOrDefault(tr => string.Equals(tr.KeyId, manifest.Signature.KeyId, StringComparison.OrdinalIgnoreCase)); if (matchingRoot is null) { signatures.Add(new ForensicSignatureDetail { KeyId = manifest.Signature.KeyId ?? "unknown", Algorithm = manifest.Signature.Algorithm, IsValid = false, IsTrusted = false, SignedAt = manifest.Signature.SignedAt, Reason = "No matching trust root found" }); return new ForensicSignatureVerification { IsValid = false, SignatureCount = 1, VerifiedSignatures = 0, Signatures = signatures }; } // Verify signature var isValid = VerifyRsaPssSignature( manifest.Digest, manifest.Signature.Value, matchingRoot.PublicKey); // Check time validity var now = DateTimeOffset.UtcNow; var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) && (!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value); if (isValid && timeValid) { verifiedCount++; } signatures.Add(new ForensicSignatureDetail { KeyId = manifest.Signature.KeyId ?? "unknown", Algorithm = manifest.Signature.Algorithm, IsValid = isValid, IsTrusted = isValid && timeValid, SignedAt = manifest.Signature.SignedAt, Fingerprint = matchingRoot.Fingerprint, Reason = !isValid ? "Signature verification failed" : !timeValid ? "Key outside validity period" : null }); return new ForensicSignatureVerification { IsValid = verifiedCount > 0, SignatureCount = 1, VerifiedSignatures = verifiedCount, Signatures = signatures }; } private ForensicChainOfCustodyVerification VerifyChainOfCustody( IReadOnlyList entries, bool strictTimeline) { var entryVerifications = new List(); var gaps = new List(); var timelineValid = true; var signaturesValid = true; DateTimeOffset? lastTimestamp = null; var index = 0; foreach (var entry in entries.OrderBy(e => e.Timestamp)) { // Check timeline progression if (lastTimestamp.HasValue && entry.Timestamp < lastTimestamp.Value) { timelineValid = false; gaps.Add(new ForensicTimelineGap { FromIndex = index - 1, ToIndex = index, FromTimestamp = lastTimestamp.Value, ToTimestamp = entry.Timestamp, GapDuration = lastTimestamp.Value - entry.Timestamp, Description = "Timestamp out of order" }); } else if (strictTimeline && lastTimestamp.HasValue) { var gap = entry.Timestamp - lastTimestamp.Value; if (gap > TimeSpan.FromDays(1)) { gaps.Add(new ForensicTimelineGap { FromIndex = index - 1, ToIndex = index, FromTimestamp = lastTimestamp.Value, ToTimestamp = entry.Timestamp, GapDuration = gap, Description = $"Large gap of {gap.TotalHours:F1} hours" }); } } // Signature verification (if present) bool? signatureValid = null; if (!string.IsNullOrWhiteSpace(entry.Signature)) { // For now, just check signature is present // Full verification would require the signing key signatureValid = true; } entryVerifications.Add(new ForensicChainOfCustodyEntryVerification { Index = index, Action = entry.Action, Actor = entry.Actor, Timestamp = entry.Timestamp, SignatureValid = signatureValid, Notes = entry.Notes }); lastTimestamp = entry.Timestamp; index++; } return new ForensicChainOfCustodyVerification { IsValid = timelineValid && signaturesValid && (gaps.Count == 0 || !strictTimeline), EntryCount = entries.Count, TimelineValid = timelineValid, SignaturesValid = signaturesValid, Entries = entryVerifications, Gaps = gaps }; } private static string ComputeDigest(byte[] data, string algorithm) { byte[] hash; switch (algorithm.ToLowerInvariant()) { case "sha256": hash = SHA256.HashData(data); break; case "sha384": hash = SHA384.HashData(data); break; case "sha512": hash = SHA512.HashData(data); break; default: hash = SHA256.HashData(data); break; } return Convert.ToHexString(hash).ToLowerInvariant(); } private static bool VerifyRsaPssSignature(string digest, string signatureBase64, string publicKeyBase64) { try { var publicKeyBytes = Convert.FromBase64String(publicKeyBase64); var signatureBytes = Convert.FromBase64String(signatureBase64); var digestBytes = Convert.FromHexString(digest); using var rsa = RSA.Create(); rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _); return rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss); } catch { return false; } } }