Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced `SbomService` tasks documentation.
- Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`.
- Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace.
- Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories.
- Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests.
- Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace.
- Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
master
2025-11-20 23:29:54 +02:00
parent 65b1599229
commit 79b8e53441
182 changed files with 6660 additions and 1242 deletions

View File

@@ -0,0 +1,17 @@
namespace StellaOps.AirGap.Importer.Contracts;
/// <summary>
/// Describes the minimal trust-root inputs the importer requires before
/// processing any offline bundle.
/// </summary>
public sealed record TrustRootConfig(
string RootBundlePath,
IReadOnlyCollection<string> TrustedKeyFingerprints,
IReadOnlyCollection<string> AllowedSignatureAlgorithms,
DateTimeOffset? NotBeforeUtc,
DateTimeOffset? NotAfterUtc,
IReadOnlyDictionary<string, byte[]> PublicKeys)
{
public static TrustRootConfig Empty(string rootBundlePath) =>
new(rootBundlePath, Array.Empty<string>(), Array.Empty<string>(), null, null, new Dictionary<string, byte[]>());
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.AirGap.Importer.Models;
public sealed record BundleCatalogEntry(
string TenantId,
string BundleId,
string Digest,
DateTimeOffset ImportedAtUtc,
IReadOnlyList<string> ContentPaths);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.AirGap.Importer.Models;
public sealed record BundleItem(
string TenantId,
string BundleId,
string Path,
string Digest,
long SizeBytes);

View File

@@ -0,0 +1,34 @@
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Planning;
/// <summary>
/// Immutable plan describing the deterministic steps taken when importing an offline bundle.
/// This keeps replay/attestation logic stable for audits.
/// </summary>
public sealed record BundleImportPlan(
IReadOnlyList<string> Steps,
IReadOnlyDictionary<string, string> Inputs,
BundleValidationResult InitialState)
{
public static BundleImportPlan FromDefaults(string bundlePath) => new List<string>
{
"load-trust-roots",
"read-bundle-manifest",
"verify-dsse-signature",
"verify-manifest-digests",
"validate-tuf-metadata",
"compute-merkle-root",
"stage-object-store",
"record-audit-entry"
}.AsReadOnlyPlan(bundlePath);
}
file static class BundleImportPlanExtensions
{
public static BundleImportPlan AsReadOnlyPlan(this IReadOnlyList<string> steps, string bundlePath) =>
new(steps, new Dictionary<string, string>
{
["bundlePath"] = bundlePath
}, BundleValidationResult.Success("plan-staged"));
}

View File

@@ -0,0 +1,39 @@
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Planning;
/// <summary>
/// Produces deterministic import plans and performs fast pre-flight checks before expensive validation work.
/// </summary>
public sealed class BundleImportPlanner
{
public BundleImportPlan CreatePlan(string bundlePath, TrustRootConfig trustRoot)
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
return new BundleImportPlan(
Array.Empty<string>(),
new Dictionary<string, string> { ["bundlePath"] = "(missing)" },
BundleValidationResult.Failure("bundle-path-required"));
}
if (trustRoot.TrustedKeyFingerprints.Count == 0)
{
return new BundleImportPlan(
new[] { "load-trust-roots" },
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
BundleValidationResult.Failure("trust-roots-required"));
}
if (trustRoot.NotAfterUtc is { } notAfter && trustRoot.NotBeforeUtc is { } notBefore && notAfter < notBefore)
{
return new BundleImportPlan(
new[] { "load-trust-roots" },
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
BundleValidationResult.Failure("invalid-trust-window"));
}
return BundleImportPlan.FromDefaults(bundlePath);
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
public interface IBundleCatalogRepository
{
Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken);
Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
public interface IBundleItemRepository
{
Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken);
Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,63 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
/// <summary>
/// Deterministic in-memory implementations suitable for offline tests and as a template for Mongo-backed repos.
/// Enforces tenant isolation and stable ordering (by BundleId then Path).
/// </summary>
public sealed class InMemoryBundleCatalogRepository : IBundleCatalogRepository
{
private readonly Dictionary<string, List<BundleCatalogEntry>> _catalog = new(StringComparer.Ordinal);
public Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var bucket = _catalog.GetValueOrDefault(entry.TenantId) ?? new List<BundleCatalogEntry>();
bucket.RemoveAll(e => e.BundleId == entry.BundleId);
bucket.Add(entry);
_catalog[entry.TenantId] = bucket;
return Task.CompletedTask;
}
public Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var items = _catalog.GetValueOrDefault(tenantId) ?? new List<BundleCatalogEntry>();
return Task.FromResult<IReadOnlyList<BundleCatalogEntry>>(items
.OrderBy(e => e.BundleId, StringComparer.Ordinal)
.ToList());
}
}
public sealed class InMemoryBundleItemRepository : IBundleItemRepository
{
private readonly Dictionary<(string TenantId, string BundleId), List<BundleItem>> _items = new();
public Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var item in items)
{
var key = (item.TenantId, item.BundleId);
if (!_items.TryGetValue(key, out var list))
{
list = new List<BundleItem>();
_items[key] = list;
}
list.RemoveAll(i => i.Path == item.Path);
list.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = (tenantId, bundleId);
var list = _items.GetValueOrDefault(key) ?? new List<BundleItem>();
return Task.FromResult<IReadOnlyList<BundleItem>>(list
.OrderBy(i => i.Path, StringComparer.Ordinal)
.ToList());
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Deterministic validation outcome used by importer and CLI surfaces.
/// </summary>
public sealed record BundleValidationResult(bool IsValid, string Reason)
{
public static BundleValidationResult Success(string reason = "ok") => new(true, reason);
public static BundleValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Importer.Validation;
public sealed record DsseEnvelope(
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures)
{
public static DsseEnvelope Parse(string json)
{
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (envelope is null)
{
throw new InvalidDataException("dsse-envelope-invalid-json");
}
return envelope;
}
}
public sealed record DsseSignature(
[property: JsonPropertyName("keyid")] string KeyId,
[property: JsonPropertyName("sig")] string Signature);

View File

@@ -0,0 +1,90 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.AirGap.Importer.Contracts;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Minimal DSSE verifier supporting RSA-PSS/SHA256. The implementation focuses on deterministic
/// pre-authentication encoding (PAE) and fingerprint checks so sealed-mode environments can run
/// without dragging additional deps.
/// </summary>
public sealed class DsseVerifier
{
private const string PaePrefix = "DSSEv1";
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots)
{
if (trustRoots.TrustedKeyFingerprints.Count == 0 || trustRoots.PublicKeys.Count == 0)
{
return BundleValidationResult.Failure("trust-roots-required");
}
foreach (var signature in envelope.Signatures)
{
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
{
continue;
}
var fingerprint = ComputeFingerprint(keyBytes);
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
{
continue;
}
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
{
return BundleValidationResult.Success("dsse-signature-verified");
}
}
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
}
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var parts = new[]
{
PaePrefix,
payloadType,
Encoding.UTF8.GetString(payloadBytes)
};
var paeBuilder = new StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private static bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
{
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
var sig = Convert.FromBase64String(signatureBase64);
return rsa.VerifyData(pae, sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
}
catch
{
return false;
}
}
private static string ComputeFingerprint(byte[] publicKey)
{
var hash = SHA256.HashData(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,61 @@
using StellaOps.AirGap.Importer.Contracts;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Coordinates DSSE, TUF, and Merkle verification for an offline import. Stateless and deterministic.
/// </summary>
public sealed class ImportValidator
{
private readonly DsseVerifier _dsse;
private readonly TufMetadataValidator _tuf;
private readonly MerkleRootCalculator _merkle;
private readonly RootRotationPolicy _rotation;
public ImportValidator()
{
_dsse = new DsseVerifier();
_tuf = new TufMetadataValidator();
_merkle = new MerkleRootCalculator();
_rotation = new RootRotationPolicy();
}
public BundleValidationResult Validate(ImportValidationRequest request)
{
var tufResult = _tuf.Validate(request.RootJson, request.SnapshotJson, request.TimestampJson);
if (!tufResult.IsValid)
{
return tufResult with { Reason = $"tuf:{tufResult.Reason}" };
}
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots);
if (!dsseResult.IsValid)
{
return dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
}
var merkleRoot = _merkle.ComputeRoot(request.PayloadEntries);
if (string.IsNullOrEmpty(merkleRoot))
{
return BundleValidationResult.Failure("merkle-empty");
}
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
if (!rotationResult.IsValid)
{
return rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
}
return BundleValidationResult.Success("import-validated");
}
}
public sealed record ImportValidationRequest(
DsseEnvelope Envelope,
TrustRootConfig TrustRoots,
string RootJson,
string SnapshotJson,
string TimestampJson,
IReadOnlyList<NamedStream> PayloadEntries,
TrustStore TrustStore,
IReadOnlyCollection<string> ApproverIds);

View File

@@ -0,0 +1,62 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Computes a deterministic Merkle-like root by hashing ordered leaf entries (path + SHA256 of content).
/// Paths are sorted ordinally to stay stable across platforms.
/// </summary>
public sealed class MerkleRootCalculator
{
public string ComputeRoot(IEnumerable<NamedStream> entries)
{
var leaves = entries
.OrderBy(e => e.Path, StringComparer.Ordinal)
.Select(HashLeaf)
.ToArray();
if (leaves.Length == 0)
{
return string.Empty;
}
while (leaves.Length > 1)
{
leaves = Pairwise(leaves).ToArray();
}
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
private static byte[] HashLeaf(NamedStream entry)
{
using var sha256 = SHA256.Create();
using var buffer = new MemoryStream();
entry.Stream.Seek(0, SeekOrigin.Begin);
entry.Stream.CopyTo(buffer);
var contentHash = sha256.ComputeHash(buffer.ToArray());
var leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
return SHA256.HashData(leafBytes);
}
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
{
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 >= nodes.Count)
{
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);
}
}
}
public sealed record NamedStream(string Path, Stream Stream);

View File

@@ -0,0 +1,32 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Enforces root rotation safety by requiring dual approval and non-empty key sets.
/// </summary>
public sealed class RootRotationPolicy
{
/// <summary>
/// Validates that pending keys can replace the active set. Requires at least two distinct approvers
/// and non-empty pending keys. Approvers should be identities recorded in audit log.
/// </summary>
public BundleValidationResult Validate(IReadOnlyDictionary<string, byte[]> activeKeys, IReadOnlyDictionary<string, byte[]> pendingKeys, IReadOnlyCollection<string> approverIds)
{
if (pendingKeys.Count == 0)
{
return BundleValidationResult.Failure("rotation-pending-empty");
}
if (approverIds.Count < 2 || approverIds.Distinct(StringComparer.Ordinal).Count() < 2)
{
return BundleValidationResult.Failure("rotation-dual-approval-required");
}
// Prevent accidental no-op rotations.
if (activeKeys.Count == pendingKeys.Count && activeKeys.Keys.All(k => pendingKeys.ContainsKey(k)))
{
return BundleValidationResult.Failure("rotation-no-change");
}
return BundleValidationResult.Success("rotation-approved");
}
}

View File

@@ -0,0 +1,53 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// In-memory trust store for DSSE/TUF public keys. Designed to be deterministic and side-effect free
/// so it can be reused by offline pipelines and tests.
/// </summary>
public sealed class TrustStore
{
private readonly Dictionary<string, byte[]> _activeKeys = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> _pendingKeys = new(StringComparer.Ordinal);
/// <summary>
/// Loads the active key set. Existing keys are replaced to keep runs deterministic.
/// </summary>
public void LoadActive(IDictionary<string, byte[]> keys)
{
_activeKeys.Clear();
foreach (var kvp in keys)
{
_activeKeys[kvp.Key] = kvp.Value.ToArray();
}
}
/// <summary>
/// Adds pending keys that require rotation approval.
/// </summary>
public void StagePending(IDictionary<string, byte[]> keys)
{
_pendingKeys.Clear();
foreach (var kvp in keys)
{
_pendingKeys[kvp.Key] = kvp.Value.ToArray();
}
}
public bool TryGetActive(string keyId, out byte[] keyBytes) => _activeKeys.TryGetValue(keyId, out keyBytes!);
public IReadOnlyDictionary<string, byte[]> ActiveKeys => _activeKeys;
public IReadOnlyDictionary<string, byte[]> PendingKeys => _pendingKeys;
/// <summary>
/// Promotes pending keys to active, after callers verify rotation policy (dual approval).
/// </summary>
public void PromotePending()
{
_activeKeys.Clear();
foreach (var kvp in _pendingKeys)
{
_activeKeys[kvp.Key] = kvp.Value;
}
_pendingKeys.Clear();
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
namespace StellaOps.AirGap.Importer.Validation;
public sealed class TufMetadataValidator
{
public BundleValidationResult Validate(string rootJson, string snapshotJson, string timestampJson)
{
try
{
var root = JsonSerializer.Deserialize<TufRoot>(rootJson, Options()) ?? throw new InvalidDataException();
var snapshot = JsonSerializer.Deserialize<TufSnapshot>(snapshotJson, Options()) ?? throw new InvalidDataException();
var timestamp = JsonSerializer.Deserialize<TufTimestamp>(timestampJson, Options()) ?? throw new InvalidDataException();
if (root.Version <= 0 || snapshot.Version <= 0 || timestamp.Version <= 0)
{
return BundleValidationResult.Failure("tuf-version-invalid");
}
if (root.ExpiresUtc <= DateTimeOffset.UnixEpoch || snapshot.ExpiresUtc <= DateTimeOffset.UnixEpoch || timestamp.ExpiresUtc <= DateTimeOffset.UnixEpoch)
{
return BundleValidationResult.Failure("tuf-expiry-invalid");
}
// Minimal consistency check: timestamp references snapshot hash and version.
if (!string.Equals(snapshot.MetadataHash, timestamp.Snapshot.Meta.Hashes.Sha256, StringComparison.OrdinalIgnoreCase))
{
return BundleValidationResult.Failure("tuf-snapshot-hash-mismatch");
}
return BundleValidationResult.Success("tuf-metadata-valid");
}
catch (Exception ex)
{
return BundleValidationResult.Failure($"tuf-parse-failed:{ex.GetType().Name.ToLowerInvariant()}");
}
}
private static JsonSerializerOptions Options() => new()
{
PropertyNameCaseInsensitive = true
};
private sealed record TufRoot(int Version, DateTimeOffset ExpiresUtc);
private sealed record TufSnapshot(int Version, DateTimeOffset ExpiresUtc, SnapshotMeta Meta)
{
public string MetadataHash => Meta?.Snapshot?.Hashes?.Sha256 ?? string.Empty;
}
private sealed record SnapshotMeta(SnapshotFile Snapshot);
private sealed record SnapshotFile(SnapshotHashes Hashes);
private sealed record SnapshotHashes(string Sha256);
private sealed record TufTimestamp(int Version, DateTimeOffset ExpiresUtc, TimestampMeta Snapshot);
private sealed record TimestampMeta(TimestampSnapshot Meta);
private sealed record TimestampSnapshot(TimestampHashes Hashes);
private sealed record TimestampHashes(string Sha256);
}