feat(api): Implement Console Export Client and Models
Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled

- Added ConsoleExportClient for managing export requests and responses.
- Introduced ConsoleExportRequest and ConsoleExportResponse models.
- Implemented methods for creating and retrieving exports with appropriate headers.

feat(crypto): Add Software SM2/SM3 Cryptography Provider

- Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography.
- Added support for signing and verification using SM2 algorithm.
- Included hashing functionality with SM3 algorithm.
- Configured options for loading keys from files and environment gate checks.

test(crypto): Add unit tests for SmSoftCryptoProvider

- Created comprehensive tests for signing, verifying, and hashing functionalities.
- Ensured correct behavior for key management and error handling.

feat(api): Enhance Console Export Models

- Expanded ConsoleExport models to include detailed status and event types.
- Added support for various export formats and notification options.

test(time): Implement TimeAnchorPolicyService tests

- Developed tests for TimeAnchorPolicyService to validate time anchors.
- Covered scenarios for anchor validation, drift calculation, and policy enforcement.
This commit is contained in:
StellaOps Bot
2025-12-07 00:27:33 +02:00
parent 9bd6a73926
commit 0de92144d2
229 changed files with 32351 additions and 1481 deletions

View File

@@ -0,0 +1,478 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Models;
using StellaOps.AirGap.Importer.Repositories;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.Cli.Services.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 ImportValidator _validator;
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));
_validator = new ImportValidator();
}
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 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 BundleItem(
request.TenantId ?? "default",
bundleId,
e.Key,
e.ArtifactDigest,
e.ArtifactSizeBytes ?? 0)) ?? Enumerable.Empty<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 = 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; }
}