// ----------------------------------------------------------------------------- // SnapshotBundleReader.cs // Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import) // Tasks: SEAL-012, SEAL-013 - Implement signature verification and merkle root validation // Description: Reads and verifies sealed knowledge snapshot bundles. // ----------------------------------------------------------------------------- using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.AirGap.Bundle.Models; using PolicySnapshotEntry = StellaOps.AirGap.Bundle.Models.PolicySnapshotEntry; namespace StellaOps.AirGap.Bundle.Services; /// /// Reads and verifies sealed knowledge snapshot bundles. /// public sealed class SnapshotBundleReader : ISnapshotBundleReader { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Reads and verifies a snapshot bundle. /// public async Task ReadAsync( SnapshotBundleReadRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath); if (!File.Exists(request.BundlePath)) { return SnapshotBundleReadResult.Failed("Bundle file not found"); } var tempDir = Path.Combine(Path.GetTempPath(), $"bundle-read-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { // Extract the bundle await ExtractBundleAsync(request.BundlePath, tempDir, cancellationToken); // Read manifest var manifestPath = Path.Combine(tempDir, "manifest.json"); if (!File.Exists(manifestPath)) { return SnapshotBundleReadResult.Failed("Manifest not found in bundle"); } var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken); var manifest = JsonSerializer.Deserialize(manifestBytes, JsonOptions); if (manifest is null) { return SnapshotBundleReadResult.Failed("Failed to parse manifest"); } var result = new SnapshotBundleReadResult { Success = true, Manifest = manifest, BundleDigest = await ComputeFileDigestAsync(request.BundlePath, cancellationToken) }; // Verify signature if requested if (request.VerifySignature) { var signaturePath = Path.Combine(tempDir, "manifest.sig"); if (File.Exists(signaturePath)) { var signatureBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken); var signatureResult = await VerifySignatureAsync( manifestBytes, signatureBytes, request.PublicKey, cancellationToken); result = result with { SignatureVerified = signatureResult.Verified, SignatureKeyId = signatureResult.KeyId, SignatureError = signatureResult.Error }; if (!signatureResult.Verified && request.RequireValidSignature) { return result with { Success = false, Error = $"Signature verification failed: {signatureResult.Error}" }; } } else if (request.RequireValidSignature) { return SnapshotBundleReadResult.Failed("Signature file not found but signature is required"); } } // Verify merkle root if requested if (request.VerifyMerkleRoot) { var merkleResult = await VerifyMerkleRootAsync(tempDir, manifest, cancellationToken); result = result with { MerkleRootVerified = merkleResult.Verified, MerkleRootError = merkleResult.Error }; if (!merkleResult.Verified && request.RequireValidMerkleRoot) { return result with { Success = false, Error = $"Merkle root verification failed: {merkleResult.Error}" }; } } // Verify time anchor if present if (request.VerifyTimeAnchor && manifest.TimeAnchor is not null) { var timeAnchorService = new TimeAnchorService(); var timeAnchorContent = new TimeAnchorContent { AnchorTime = manifest.TimeAnchor.AnchorTime, Source = manifest.TimeAnchor.Source, TokenDigest = manifest.TimeAnchor.Digest }; var timeAnchorResult = await timeAnchorService.ValidateAnchorAsync( timeAnchorContent, new TimeAnchorValidationRequest { MaxAgeHours = request.MaxAgeHours, MaxClockDriftSeconds = request.MaxClockDriftSeconds }, cancellationToken); result = result with { TimeAnchorValid = timeAnchorResult.IsValid, TimeAnchorAgeHours = timeAnchorResult.AgeHours, TimeAnchorError = timeAnchorResult.Error }; if (!timeAnchorResult.IsValid && request.RequireValidTimeAnchor) { return result with { Success = false, Error = $"Time anchor validation failed: {timeAnchorResult.Error}" }; } } return result; } catch (Exception ex) { return SnapshotBundleReadResult.Failed($"Failed to read bundle: {ex.Message}"); } finally { // Clean up temp directory try { if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); } } catch { // Ignore cleanup errors } } } private static async Task ExtractBundleAsync(string bundlePath, string targetDir, CancellationToken ct) { await using var fileStream = File.OpenRead(bundlePath); await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); await TarFile.ExtractToDirectoryAsync(gzipStream, targetDir, overwriteFiles: true, ct); } private static async Task ComputeFileDigestAsync(string filePath, CancellationToken ct) { await using var stream = File.OpenRead(filePath); var hash = await SHA256.HashDataAsync(stream, ct); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static async Task VerifySignatureAsync( byte[] manifestBytes, byte[] signatureEnvelopeBytes, AsymmetricAlgorithm? publicKey, CancellationToken cancellationToken) { try { var signer = new SnapshotManifestSigner(); var result = await signer.VerifyAsync( new ManifestVerificationRequest { EnvelopeBytes = signatureEnvelopeBytes, PublicKey = publicKey }, cancellationToken); if (!result.Success) { return new SignatureVerificationResult { Verified = false, Error = result.Error }; } // Verify the payload digest matches the manifest var manifestDigest = ComputeSha256(manifestBytes); if (result.PayloadDigest != manifestDigest) { return new SignatureVerificationResult { Verified = false, Error = "Manifest digest does not match signed payload" }; } var keyId = result.VerifiedSignatures?.FirstOrDefault()?.KeyId; return new SignatureVerificationResult { Verified = publicKey is null || (result.VerifiedSignatures?.Any(s => s.Verified == true) ?? false), KeyId = keyId }; } catch (Exception ex) { return new SignatureVerificationResult { Verified = false, Error = ex.Message }; } } private static async Task VerifyMerkleRootAsync( string bundleDir, KnowledgeSnapshotManifest manifest, CancellationToken cancellationToken) { try { var entries = new List(); // Collect all entries from manifest foreach (var advisory in manifest.Advisories) { var filePath = Path.Combine(bundleDir, advisory.RelativePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(filePath)) { return new MerkleVerificationResult { Verified = false, Error = $"Missing file: {advisory.RelativePath}" }; } var content = await File.ReadAllBytesAsync(filePath, cancellationToken); var digest = ComputeSha256(content); if (digest != advisory.Digest) { return new MerkleVerificationResult { Verified = false, Error = $"Digest mismatch for {advisory.RelativePath}" }; } entries.Add(new BundleEntry(advisory.RelativePath, digest, content.Length)); } foreach (var vex in manifest.VexStatements) { var filePath = Path.Combine(bundleDir, vex.RelativePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(filePath)) { return new MerkleVerificationResult { Verified = false, Error = $"Missing file: {vex.RelativePath}" }; } var content = await File.ReadAllBytesAsync(filePath, cancellationToken); var digest = ComputeSha256(content); if (digest != vex.Digest) { return new MerkleVerificationResult { Verified = false, Error = $"Digest mismatch for {vex.RelativePath}" }; } entries.Add(new BundleEntry(vex.RelativePath, digest, content.Length)); } foreach (var policy in manifest.Policies) { var filePath = Path.Combine(bundleDir, policy.RelativePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(filePath)) { return new MerkleVerificationResult { Verified = false, Error = $"Missing file: {policy.RelativePath}" }; } var content = await File.ReadAllBytesAsync(filePath, cancellationToken); var digest = ComputeSha256(content); if (digest != policy.Digest) { return new MerkleVerificationResult { Verified = false, Error = $"Digest mismatch for {policy.RelativePath}" }; } entries.Add(new BundleEntry(policy.RelativePath, digest, content.Length)); } foreach (var trust in manifest.TrustRoots) { var filePath = Path.Combine(bundleDir, trust.RelativePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(filePath)) { return new MerkleVerificationResult { Verified = false, Error = $"Missing file: {trust.RelativePath}" }; } var content = await File.ReadAllBytesAsync(filePath, cancellationToken); var digest = ComputeSha256(content); if (digest != trust.Digest) { return new MerkleVerificationResult { Verified = false, Error = $"Digest mismatch for {trust.RelativePath}" }; } entries.Add(new BundleEntry(trust.RelativePath, digest, content.Length)); } // Compute merkle root var computedRoot = ComputeMerkleRoot(entries); if (computedRoot != manifest.MerkleRoot) { return new MerkleVerificationResult { Verified = false, Error = $"Merkle root mismatch: expected {manifest.MerkleRoot}, got {computedRoot}" }; } return new MerkleVerificationResult { Verified = true }; } catch (Exception ex) { return new MerkleVerificationResult { Verified = false, Error = ex.Message }; } } private static string ComputeSha256(byte[] content) { var hash = SHA256.HashData(content); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static string ComputeMerkleRoot(List entries) { if (entries.Count == 0) { return string.Empty; } var leaves = entries .OrderBy(e => e.Path, StringComparer.Ordinal) .Select(e => SHA256.HashData(Encoding.UTF8.GetBytes($"{e.Path}:{e.Digest}"))) .ToArray(); while (leaves.Length > 1) { leaves = PairwiseHash(leaves).ToArray(); } return Convert.ToHexString(leaves[0]).ToLowerInvariant(); } private static IEnumerable PairwiseHash(byte[][] nodes) { for (var i = 0; i < nodes.Length; i += 2) { if (i + 1 >= nodes.Length) { yield return SHA256.HashData(nodes[i]); continue; } var combined = new byte[nodes[i].Length + nodes[i + 1].Length]; Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length); Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length); yield return SHA256.HashData(combined); } } private sealed record BundleEntry(string Path, string Digest, long SizeBytes); private sealed record SignatureVerificationResult { public bool Verified { get; init; } public string? KeyId { get; init; } public string? Error { get; init; } } private sealed record MerkleVerificationResult { public bool Verified { get; init; } public string? Error { get; init; } } } /// /// Interface for snapshot bundle reading. /// public interface ISnapshotBundleReader { Task ReadAsync( SnapshotBundleReadRequest request, CancellationToken cancellationToken = default); } #region Request and Result Models /// /// Request for reading a snapshot bundle. /// public sealed record SnapshotBundleReadRequest { public required string BundlePath { get; init; } /// /// Verify the manifest signature. /// public bool VerifySignature { get; init; } = true; /// /// Fail if signature is invalid. /// public bool RequireValidSignature { get; init; } /// /// Verify the merkle root. /// public bool VerifyMerkleRoot { get; init; } = true; /// /// Fail if merkle root is invalid. /// public bool RequireValidMerkleRoot { get; init; } = true; /// /// Verify time anchor freshness. /// public bool VerifyTimeAnchor { get; init; } = true; /// /// Fail if time anchor is invalid. /// public bool RequireValidTimeAnchor { get; init; } /// /// Maximum age in hours for time anchor validation. /// public int? MaxAgeHours { get; init; } /// /// Maximum clock drift in seconds for time anchor validation. /// public int? MaxClockDriftSeconds { get; init; } /// /// Public key for signature verification. /// public AsymmetricAlgorithm? PublicKey { get; init; } } /// /// Result of reading a snapshot bundle. /// public sealed record SnapshotBundleReadResult { public bool Success { get; init; } public KnowledgeSnapshotManifest? Manifest { get; init; } public string? BundleDigest { get; init; } public string? Error { get; init; } // Signature verification public bool? SignatureVerified { get; init; } public string? SignatureKeyId { get; init; } public string? SignatureError { get; init; } // Merkle root verification public bool? MerkleRootVerified { get; init; } public string? MerkleRootError { get; init; } // Time anchor verification public bool? TimeAnchorValid { get; init; } public double? TimeAnchorAgeHours { get; init; } public string? TimeAnchorError { get; init; } public static SnapshotBundleReadResult Failed(string error) => new() { Success = false, Error = error }; } #endregion