using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.AirGap.Importer.Contracts; using StellaOps.AirGap.Importer.Repositories; using StellaOps.AirGap.Importer.Validation; using StellaOps.Cli.Services.Models; using ImportModels = StellaOps.AirGap.Importer.Models; namespace StellaOps.Cli.Services; /// /// Service for importing mirror bundles with DSSE, TUF, and Merkle verification. /// CLI-AIRGAP-56-001: Extends CLI offline kit tooling to consume mirror bundles. /// public interface IMirrorBundleImportService { Task ImportAsync(MirrorImportRequest request, CancellationToken cancellationToken); Task VerifyAsync(string bundlePath, string? trustRootsPath, CancellationToken cancellationToken); } public sealed class MirrorBundleImportService : IMirrorBundleImportService { private readonly IBundleCatalogRepository _catalogRepository; private readonly IBundleItemRepository _itemRepository; private readonly ILogger _logger; public MirrorBundleImportService( IBundleCatalogRepository catalogRepository, IBundleItemRepository itemRepository, ILogger logger) { _catalogRepository = catalogRepository ?? throw new ArgumentNullException(nameof(catalogRepository)); _itemRepository = itemRepository ?? throw new ArgumentNullException(nameof(itemRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task ImportAsync(MirrorImportRequest request, CancellationToken cancellationToken) { _logger.LogDebug("Starting bundle import from {BundlePath}", request.BundlePath); // Parse manifest var manifestResult = await ParseManifestAsync(request.BundlePath, cancellationToken).ConfigureAwait(false); if (!manifestResult.Success) { return MirrorImportResult.Failed(manifestResult.Error!); } var manifest = manifestResult.Manifest!; var bundleDir = Path.GetDirectoryName(manifestResult.ManifestPath)!; // Verify checksums var checksumResult = await VerifyChecksumsAsync(bundleDir, cancellationToken).ConfigureAwait(false); // If DSSE envelope exists, perform cryptographic verification var dsseResult = await VerifyDsseIfPresentAsync(bundleDir, request.TrustRootsPath, cancellationToken).ConfigureAwait(false); // Copy artifacts to data store var dataStorePath = GetDataStorePath(request.TenantId, manifest.DomainId); var importedPaths = new List(); if (!request.DryRun) { importedPaths = await CopyArtifactsAsync(bundleDir, dataStorePath, manifest, cancellationToken).ConfigureAwait(false); // Register in catalog var bundleId = GenerateBundleId(manifest); var manifestDigest = ComputeDigest(File.ReadAllBytes(manifestResult.ManifestPath!)); var catalogEntry = new ImportModels.BundleCatalogEntry( request.TenantId ?? "default", bundleId, manifestDigest, DateTimeOffset.UtcNow, importedPaths); await _catalogRepository.UpsertAsync(catalogEntry, cancellationToken).ConfigureAwait(false); // Register individual items var items = manifest.Exports?.Select(e => new ImportModels.BundleItem( request.TenantId ?? "default", bundleId, e.Key, e.ArtifactDigest, e.ArtifactSizeBytes ?? 0)) ?? Enumerable.Empty(); await _itemRepository.UpsertManyAsync(items, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Imported bundle {BundleId} with {Count} exports", bundleId, manifest.Exports?.Count ?? 0); } return new MirrorImportResult { Success = true, ManifestPath = manifestResult.ManifestPath, DomainId = manifest.DomainId, DisplayName = manifest.DisplayName, GeneratedAt = manifest.GeneratedAt, ExportCount = manifest.Exports?.Count ?? 0, ChecksumVerification = checksumResult, DsseVerification = dsseResult, ImportedPaths = importedPaths, DryRun = request.DryRun }; } public async Task VerifyAsync(string bundlePath, string? trustRootsPath, CancellationToken cancellationToken) { var manifestResult = await ParseManifestAsync(bundlePath, cancellationToken).ConfigureAwait(false); if (!manifestResult.Success) { return new MirrorVerificationResult { Success = false, Error = manifestResult.Error }; } var bundleDir = Path.GetDirectoryName(manifestResult.ManifestPath)!; var checksumResult = await VerifyChecksumsAsync(bundleDir, cancellationToken).ConfigureAwait(false); var dsseResult = await VerifyDsseIfPresentAsync(bundleDir, trustRootsPath, cancellationToken).ConfigureAwait(false); var allValid = checksumResult.AllValid && (dsseResult?.IsValid ?? true); return new MirrorVerificationResult { Success = allValid, ManifestPath = manifestResult.ManifestPath, DomainId = manifestResult.Manifest!.DomainId, ChecksumVerification = checksumResult, DsseVerification = dsseResult }; } private async Task ParseManifestAsync(string bundlePath, CancellationToken cancellationToken) { var resolvedPath = Path.GetFullPath(bundlePath); string manifestPath; if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { manifestPath = resolvedPath; } else if (Directory.Exists(resolvedPath)) { var candidates = Directory.GetFiles(resolvedPath, "*-manifest.json") .Concat(Directory.GetFiles(resolvedPath, "manifest.json")) .ToArray(); if (candidates.Length == 0) { return ManifestParseResult.Failed("No manifest file found in bundle directory"); } manifestPath = candidates.OrderByDescending(File.GetLastWriteTimeUtc).First(); } else { return ManifestParseResult.Failed($"Bundle path not found: {resolvedPath}"); } try { var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); var manifest = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (manifest is null) { return ManifestParseResult.Failed("Failed to parse bundle manifest"); } return new ManifestParseResult { Success = true, ManifestPath = manifestPath, Manifest = manifest }; } catch (JsonException ex) { return ManifestParseResult.Failed($"Invalid manifest JSON: {ex.Message}"); } } private async Task VerifyChecksumsAsync(string bundleDir, CancellationToken cancellationToken) { var checksumPath = Path.Combine(bundleDir, "SHA256SUMS"); var results = new List(); var allValid = true; if (!File.Exists(checksumPath)) { return new ChecksumVerificationResult { ChecksumFileFound = false, AllValid = true, Results = results }; } var lines = await File.ReadAllLinesAsync(checksumPath, cancellationToken).ConfigureAwait(false); foreach (var line in lines.Where(l => !string.IsNullOrWhiteSpace(l))) { var parts = line.Split([' ', '\t'], 2, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) continue; var expected = parts[0].Trim(); var fileName = parts[1].Trim().TrimStart('*'); var filePath = Path.Combine(bundleDir, fileName); if (!File.Exists(filePath)) { results.Add(new FileChecksumResult(fileName, expected, "(missing)", false)); allValid = false; continue; } var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false); var actual = ComputeDigest(fileBytes); var isValid = string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase) || string.Equals($"sha256:{expected}", actual, StringComparison.OrdinalIgnoreCase); results.Add(new FileChecksumResult(fileName, expected, actual, isValid)); if (!isValid) allValid = false; } return new ChecksumVerificationResult { ChecksumFileFound = true, AllValid = allValid, Results = results }; } private async Task VerifyDsseIfPresentAsync(string bundleDir, string? trustRootsPath, CancellationToken cancellationToken) { // Look for DSSE envelope var dsseFiles = Directory.GetFiles(bundleDir, "*.dsse.json") .Concat(Directory.GetFiles(bundleDir, "*envelope.json")) .ToArray(); if (dsseFiles.Length == 0) { return null; // No DSSE envelope present - verification not required } var dsseFile = dsseFiles.OrderByDescending(File.GetLastWriteTimeUtc).First(); try { var envelopeJson = await File.ReadAllTextAsync(dsseFile, cancellationToken).ConfigureAwait(false); var envelope = StellaOps.AirGap.Importer.Validation.DsseEnvelope.Parse(envelopeJson); // Load trust roots if provided TrustRootConfig trustRoots; if (!string.IsNullOrWhiteSpace(trustRootsPath) && File.Exists(trustRootsPath)) { trustRoots = await LoadTrustRootsAsync(trustRootsPath, cancellationToken).ConfigureAwait(false); } else { // Try default trust roots location var defaultTrustRoots = Path.Combine(bundleDir, "trust-roots.json"); if (File.Exists(defaultTrustRoots)) { trustRoots = await LoadTrustRootsAsync(defaultTrustRoots, cancellationToken).ConfigureAwait(false); } else { return new DsseVerificationResult { IsValid = false, EnvelopePath = dsseFile, Error = "No trust roots available for DSSE verification" }; } } var verifier = new DsseVerifier(); var result = verifier.Verify(envelope, trustRoots); return new DsseVerificationResult { IsValid = result.IsValid, EnvelopePath = dsseFile, KeyId = envelope.Signatures.FirstOrDefault()?.KeyId, Reason = result.Reason }; } catch (Exception ex) { return new DsseVerificationResult { IsValid = false, EnvelopePath = dsseFile, Error = $"Failed to verify DSSE: {ex.Message}" }; } } private static async Task LoadTrustRootsAsync(string path, CancellationToken cancellationToken) { var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); var doc = JsonDocument.Parse(json); var fingerprints = new List(); var algorithms = new List(); var publicKeys = new Dictionary(); if (doc.RootElement.TryGetProperty("trustedKeyFingerprints", out var fps)) { foreach (var fp in fps.EnumerateArray()) { fingerprints.Add(fp.GetString() ?? string.Empty); } } if (doc.RootElement.TryGetProperty("allowedAlgorithms", out var algs)) { foreach (var alg in algs.EnumerateArray()) { algorithms.Add(alg.GetString() ?? string.Empty); } } if (doc.RootElement.TryGetProperty("publicKeys", out var keys)) { foreach (var key in keys.EnumerateObject()) { var keyData = key.Value.GetString(); if (!string.IsNullOrEmpty(keyData)) { publicKeys[key.Name] = Convert.FromBase64String(keyData); } } } return new TrustRootConfig(path, fingerprints, algorithms, null, null, publicKeys); } private async Task> CopyArtifactsAsync(string bundleDir, string dataStorePath, MirrorBundle manifest, CancellationToken cancellationToken) { Directory.CreateDirectory(dataStorePath); var importedPaths = new List(); // Copy manifest var manifestFiles = Directory.GetFiles(bundleDir, "*manifest.json"); foreach (var file in manifestFiles) { var destPath = Path.Combine(dataStorePath, Path.GetFileName(file)); await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false); importedPaths.Add(destPath); } // Copy export artifacts foreach (var export in manifest.Exports ?? Enumerable.Empty()) { var exportFiles = Directory.GetFiles(bundleDir, $"*{export.ExportId}*") .Concat(Directory.GetFiles(bundleDir, $"*{export.Key}*")); foreach (var file in exportFiles.Distinct()) { var destPath = Path.Combine(dataStorePath, Path.GetFileName(file)); await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false); importedPaths.Add(destPath); } } // Copy checksums and signatures var supportFiles = new[] { "SHA256SUMS", "*.sig", "*.dsse.json" }; foreach (var pattern in supportFiles) { foreach (var file in Directory.GetFiles(bundleDir, pattern)) { var destPath = Path.Combine(dataStorePath, Path.GetFileName(file)); await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false); importedPaths.Add(destPath); } } return importedPaths; } private static async Task CopyFileAsync(string source, string destination, CancellationToken cancellationToken) { await using var sourceStream = File.OpenRead(source); await using var destStream = File.Create(destination); await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false); } private static string GetDataStorePath(string? tenantId, string domainId) { var basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var stellaPath = Path.Combine(basePath, "stellaops", "offline-kit", "data"); return Path.Combine(stellaPath, tenantId ?? "default", domainId); } private static string GenerateBundleId(MirrorBundle manifest) { return $"{manifest.DomainId}-{manifest.GeneratedAt:yyyyMMddHHmmss}"; } private static string ComputeDigest(byte[] data) { return $"sha256:{Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant()}"; } private sealed record ManifestParseResult { public bool Success { get; init; } public string? ManifestPath { get; init; } public MirrorBundle? Manifest { get; init; } public string? Error { get; init; } public static ManifestParseResult Failed(string error) => new() { Success = false, Error = error }; } } /// /// Request for importing a mirror bundle. /// public sealed record MirrorImportRequest { public required string BundlePath { get; init; } public string? TenantId { get; init; } public string? TrustRootsPath { get; init; } public bool DryRun { get; init; } public bool Force { get; init; } } /// /// Result of a mirror bundle import operation. /// public sealed record MirrorImportResult { public bool Success { get; init; } public string? Error { get; init; } public string? ManifestPath { get; init; } public string? DomainId { get; init; } public string? DisplayName { get; init; } public DateTimeOffset GeneratedAt { get; init; } public int ExportCount { get; init; } public ChecksumVerificationResult? ChecksumVerification { get; init; } public DsseVerificationResult? DsseVerification { get; init; } public IReadOnlyList ImportedPaths { get; init; } = Array.Empty(); public bool DryRun { get; init; } public static MirrorImportResult Failed(string error) => new() { Success = false, Error = error }; } /// /// Result of mirror bundle verification. /// public sealed record MirrorVerificationResult { public bool Success { get; init; } public string? Error { get; init; } public string? ManifestPath { get; init; } public string? DomainId { get; init; } public ChecksumVerificationResult? ChecksumVerification { get; init; } public DsseVerificationResult? DsseVerification { get; init; } } /// /// Checksum verification results. /// public sealed record ChecksumVerificationResult { public bool ChecksumFileFound { get; init; } public bool AllValid { get; init; } public IReadOnlyList Results { get; init; } = Array.Empty(); } /// /// Individual file checksum result. /// public sealed record FileChecksumResult(string FileName, string Expected, string Actual, bool IsValid); /// /// DSSE verification result. /// public sealed record DsseVerificationResult { public bool IsValid { get; init; } public string? EnvelopePath { get; init; } public string? KeyId { get; init; } public string? Reason { get; init; } public string? Error { get; init; } }