477 lines
18 KiB
C#
477 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service for importing mirror bundles with DSSE, TUF, and Merkle verification.
|
|
/// CLI-AIRGAP-56-001: Extends CLI offline kit tooling to consume mirror bundles.
|
|
/// </summary>
|
|
public interface IMirrorBundleImportService
|
|
{
|
|
Task<MirrorImportResult> ImportAsync(MirrorImportRequest request, CancellationToken cancellationToken);
|
|
Task<MirrorVerificationResult> VerifyAsync(string bundlePath, string? trustRootsPath, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class MirrorBundleImportService : IMirrorBundleImportService
|
|
{
|
|
private readonly IBundleCatalogRepository _catalogRepository;
|
|
private readonly IBundleItemRepository _itemRepository;
|
|
private readonly ILogger<MirrorBundleImportService> _logger;
|
|
|
|
public MirrorBundleImportService(
|
|
IBundleCatalogRepository catalogRepository,
|
|
IBundleItemRepository itemRepository,
|
|
ILogger<MirrorBundleImportService> 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<MirrorImportResult> 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<string>();
|
|
|
|
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<ImportModels.BundleItem>();
|
|
|
|
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<MirrorVerificationResult> 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<ManifestParseResult> 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<MirrorBundle>(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<ChecksumVerificationResult> VerifyChecksumsAsync(string bundleDir, CancellationToken cancellationToken)
|
|
{
|
|
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
|
|
var results = new List<FileChecksumResult>();
|
|
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<DsseVerificationResult?> 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<TrustRootConfig> LoadTrustRootsAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
var fingerprints = new List<string>();
|
|
var algorithms = new List<string>();
|
|
var publicKeys = new Dictionary<string, byte[]>();
|
|
|
|
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<List<string>> CopyArtifactsAsync(string bundleDir, string dataStorePath, MirrorBundle manifest, CancellationToken cancellationToken)
|
|
{
|
|
Directory.CreateDirectory(dataStorePath);
|
|
var importedPaths = new List<string>();
|
|
|
|
// 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<MirrorBundleExport>())
|
|
{
|
|
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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request for importing a mirror bundle.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of a mirror bundle import operation.
|
|
/// </summary>
|
|
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<string> ImportedPaths { get; init; } = Array.Empty<string>();
|
|
public bool DryRun { get; init; }
|
|
|
|
public static MirrorImportResult Failed(string error) => new() { Success = false, Error = error };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of mirror bundle verification.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checksum verification results.
|
|
/// </summary>
|
|
public sealed record ChecksumVerificationResult
|
|
{
|
|
public bool ChecksumFileFound { get; init; }
|
|
public bool AllValid { get; init; }
|
|
public IReadOnlyList<FileChecksumResult> Results { get; init; } = Array.Empty<FileChecksumResult>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Individual file checksum result.
|
|
/// </summary>
|
|
public sealed record FileChecksumResult(string FileName, string Expected, string Actual, bool IsValid);
|
|
|
|
/// <summary>
|
|
/// DSSE verification result.
|
|
/// </summary>
|
|
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; }
|
|
}
|