up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
master
2025-11-28 18:21:46 +02:00
parent 05da719048
commit d1cbb905f8
103 changed files with 49604 additions and 105 deletions

View File

@@ -0,0 +1,592 @@
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;
/// <summary>
/// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody.
/// Per CLI-FORENSICS-54-001.
/// </summary>
internal sealed class ForensicVerifier : IForensicVerifier
{
private const string PaePrefix = "DSSEv1";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly ILogger<ForensicVerifier> _logger;
public ForensicVerifier(ILogger<ForensicVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ForensicVerificationResult> VerifyBundleAsync(
string bundlePath,
ForensicVerificationOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
ArgumentNullException.ThrowIfNull(options);
var errors = new List<ForensicVerificationError>();
var warnings = new List<string>();
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<ForensicSnapshotManifest>(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<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
string trustRootPath,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath);
if (!File.Exists(trustRootPath))
{
_logger.LogWarning("Trust root file not found: {Path}", trustRootPath);
return Array.Empty<ForensicTrustRoot>();
}
try
{
var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false);
// Try array format first
var roots = JsonSerializer.Deserialize<List<ForensicTrustRoot>>(json, SerializerOptions);
if (roots is not null)
{
return roots;
}
// Try single object
var singleRoot = JsonSerializer.Deserialize<ForensicTrustRoot>(json, SerializerOptions);
if (singleRoot is not null)
{
return new[] { singleRoot };
}
return Array.Empty<ForensicTrustRoot>();
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath);
return Array.Empty<ForensicTrustRoot>();
}
}
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<ForensicManifestVerification> 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<ForensicChecksumVerification> VerifyChecksumsAsync(
ForensicSnapshotManifest manifest,
string bundleDir,
CancellationToken cancellationToken)
{
var failures = new List<ForensicArtifactChecksumFailure>();
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<ForensicTrustRoot> trustRoots)
{
if (manifest.Signature is null)
{
return new ForensicSignatureVerification
{
IsValid = false,
SignatureCount = 0,
VerifiedSignatures = 0,
Signatures = Array.Empty<ForensicSignatureDetail>()
};
}
var signatures = new List<ForensicSignatureDetail>();
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<ForensicChainOfCustodyEntry> entries,
bool strictTimeline)
{
var entryVerifications = new List<ForensicChainOfCustodyEntryVerification>();
var gaps = new List<ForensicTimelineGap>();
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;
}
}
}