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
593 lines
21 KiB
C#
593 lines
21 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|