- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
549 lines
18 KiB
C#
549 lines
18 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Reads and verifies sealed knowledge snapshot bundles.
|
|
/// </summary>
|
|
public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Reads and verifies a snapshot bundle.
|
|
/// </summary>
|
|
public async Task<SnapshotBundleReadResult> 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<KnowledgeSnapshotManifest>(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<string> 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<SignatureVerificationResult> 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<MerkleVerificationResult> VerifyMerkleRootAsync(
|
|
string bundleDir,
|
|
KnowledgeSnapshotManifest manifest,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var entries = new List<BundleEntry>();
|
|
|
|
// 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<BundleEntry> 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<byte[]> 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; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for snapshot bundle reading.
|
|
/// </summary>
|
|
public interface ISnapshotBundleReader
|
|
{
|
|
Task<SnapshotBundleReadResult> ReadAsync(
|
|
SnapshotBundleReadRequest request,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
#region Request and Result Models
|
|
|
|
/// <summary>
|
|
/// Request for reading a snapshot bundle.
|
|
/// </summary>
|
|
public sealed record SnapshotBundleReadRequest
|
|
{
|
|
public required string BundlePath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Verify the manifest signature.
|
|
/// </summary>
|
|
public bool VerifySignature { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Fail if signature is invalid.
|
|
/// </summary>
|
|
public bool RequireValidSignature { get; init; }
|
|
|
|
/// <summary>
|
|
/// Verify the merkle root.
|
|
/// </summary>
|
|
public bool VerifyMerkleRoot { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Fail if merkle root is invalid.
|
|
/// </summary>
|
|
public bool RequireValidMerkleRoot { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Verify time anchor freshness.
|
|
/// </summary>
|
|
public bool VerifyTimeAnchor { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Fail if time anchor is invalid.
|
|
/// </summary>
|
|
public bool RequireValidTimeAnchor { get; init; }
|
|
|
|
/// <summary>
|
|
/// Maximum age in hours for time anchor validation.
|
|
/// </summary>
|
|
public int? MaxAgeHours { get; init; }
|
|
|
|
/// <summary>
|
|
/// Maximum clock drift in seconds for time anchor validation.
|
|
/// </summary>
|
|
public int? MaxClockDriftSeconds { get; init; }
|
|
|
|
/// <summary>
|
|
/// Public key for signature verification.
|
|
/// </summary>
|
|
public AsymmetricAlgorithm? PublicKey { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of reading a snapshot bundle.
|
|
/// </summary>
|
|
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
|