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,64 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Controllers;
[ApiController]
[Route("api/v1/time")]
public class TimeStatusController : ControllerBase
{
private readonly TimeStatusService _statusService;
private readonly TimeAnchorLoader _loader;
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader)
{
_statusService = statusService;
_loader = loader;
}
[HttpGet("status")]
public async Task<ActionResult<TimeStatusDto>> GetStatus([FromQuery] string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return BadRequest("tenantId-required");
}
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
[HttpPost("anchor")]
public async Task<ActionResult<TimeStatusDto>> SetAnchor([FromBody] SetAnchorRequest request)
{
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
var trustRoot = new TimeTrustRoot(
request.TrustRootKeyId,
Convert.FromBase64String(request.TrustRootPublicKeyBase64),
request.TrustRootAlgorithm);
var result = _loader.TryLoadHex(
request.HexToken,
request.Format,
new[] { trustRoot },
out var anchor);
if (!result.IsValid)
{
return BadRequest(result.Reason);
}
var budget = new StalenessBudget(
request.WarningSeconds ?? StalenessBudget.Default.WarningSeconds,
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Models;
public sealed class SetAnchorRequest
{
[Required]
public string TenantId { get; set; } = string.Empty;
[Required]
public string HexToken { get; set; } = string.Empty;
[Required]
public TimeTokenFormat Format { get; set; }
[Required]
public string TrustRootKeyId { get; set; } = string.Empty;
[Required]
public string TrustRootAlgorithm { get; set; } = string.Empty;
[Required]
public string TrustRootPublicKeyBase64 { get; set; } = string.Empty;
public long? WarningSeconds { get; set; }
public long? BreachSeconds { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.AirGap.Time.Models;
/// <summary>
/// Represents tolerated staleness for time anchors. Budgets are seconds and must be non-negative.
/// </summary>
public sealed record StalenessBudget(long WarningSeconds, long BreachSeconds)
{
public static StalenessBudget Default => new(3600, 7200);
public void Validate()
{
if (WarningSeconds < 0 || BreachSeconds < 0)
{
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "budgets-must-be-non-negative");
}
if (WarningSeconds > BreachSeconds)
{
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "warning-cannot-exceed-breach");
}
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record StalenessEvaluation(
long AgeSeconds,
long WarningSeconds,
long BreachSeconds,
bool IsWarning,
bool IsBreach)
{
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.AirGap.Time.Models;
/// <summary>
/// Canonical representation of a trusted time anchor extracted from a signed token.
/// </summary>
public sealed record TimeAnchor(
DateTimeOffset AnchorTime,
string Source,
string Format,
string SignatureFingerprint,
string TokenDigest)
{
public static TimeAnchor Unknown => new(DateTimeOffset.MinValue, "unknown", "unknown", "", "");
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeStatus(
TimeAnchor Anchor,
StalenessEvaluation Staleness,
StalenessBudget Budget,
DateTimeOffset EvaluatedAtUtc)
{
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeStatusDto(
[property: JsonPropertyName("anchorTime")] string AnchorTime,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("fingerprint")] string Fingerprint,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("ageSeconds")] long AgeSeconds,
[property: JsonPropertyName("warningSeconds")] long WarningSeconds,
[property: JsonPropertyName("breachSeconds")] long BreachSeconds,
[property: JsonPropertyName("isWarning")] bool IsWarning,
[property: JsonPropertyName("isBreach")] bool IsBreach,
[property: JsonPropertyName("evaluatedAtUtc")] string EvaluatedAtUtc)
{
public static TimeStatusDto FromStatus(TimeStatus status)
{
return new TimeStatusDto(
status.Anchor.AnchorTime.ToUniversalTime().ToString("O"),
status.Anchor.Format,
status.Anchor.Source,
status.Anchor.SignatureFingerprint,
status.Anchor.TokenDigest,
status.Staleness.AgeSeconds,
status.Staleness.WarningSeconds,
status.Staleness.BreachSeconds,
status.Staleness.IsWarning,
status.Staleness.IsBreach,
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
}
public string ToJson()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false
};
return JsonSerializer.Serialize(this, options);
}
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeTrustRoot(string KeyId, byte[] PublicKey, string Algorithm);

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Time.Parsing;
/// <summary>
/// Validation result for a time anchor parse/verify attempt.
/// </summary>
public sealed record TimeAnchorValidationResult(bool IsValid, string Reason)
{
public static TimeAnchorValidationResult Success(string reason = "ok") => new(true, reason);
public static TimeAnchorValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.AirGap.Time.Parsing;
public enum TimeTokenFormat
{
Roughtime,
Rfc3161
}

View File

@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Parsing;
/// <summary>
/// Performs minimal, deterministic parsing of signed time tokens. Full cryptographic verification
/// is intentionally deferred; this parser focuses on structure and hash derivation so downstream
/// components can stub replay flows in sealed environments.
/// </summary>
public sealed class TimeTokenParser
{
public TimeAnchorValidationResult TryParse(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
// Derive a deterministic anchor time from digest bytes (no wall clock use).
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365)); // wrap within ~1y for stability
switch (format)
{
case TimeTokenFormat.Roughtime:
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", "(pending)", digest);
return TimeAnchorValidationResult.Success("structure-stubbed");
case TimeTokenFormat.Rfc3161:
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", "(pending)", digest);
return TimeAnchorValidationResult.Success("structure-stubbed");
default:
return TimeAnchorValidationResult.Failure("unknown-format");
}
}
}

View File

@@ -0,0 +1,19 @@
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StalenessCalculator>();
builder.Services.AddSingleton<TimeStatusService>();
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
builder.Services.AddSingleton<TimeVerificationService>();
builder.Services.AddSingleton<TimeAnchorLoader>();
builder.Services.AddSingleton<TimeTokenParser>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,10 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public interface ITimeTokenVerifier
{
TimeTokenFormat Format { get; }
TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor);
}

View File

@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class Rfc3161Verifier : ITimeTokenVerifier
{
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (trustRoots.Count == 0)
{
return TimeAnchorValidationResult.Failure("trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
// Stub: derive anchor time deterministically; real ASN.1 verification to be added once trust roots finalized.
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", trustRoots[0].KeyId, digest);
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
}
}

View File

@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class RoughtimeVerifier : ITimeTokenVerifier
{
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (trustRoots.Count == 0)
{
return TimeAnchorValidationResult.Failure("trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
// Stub: derive anchor time deterministically from digest until real Roughtime decoding is wired.
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", trustRoots[0].KeyId, digest);
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
}
}

View File

@@ -0,0 +1,25 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Computes staleness for a given time anchor against configured budgets.
/// </summary>
public sealed class StalenessCalculator
{
public StalenessEvaluation Evaluate(TimeAnchor anchor, StalenessBudget budget, DateTimeOffset nowUtc)
{
budget.Validate();
if (anchor.AnchorTime == DateTimeOffset.MinValue)
{
return StalenessEvaluation.Unknown;
}
var ageSeconds = Math.Max(0, (long)(nowUtc - anchor.AnchorTime).TotalSeconds);
var isBreach = ageSeconds >= budget.BreachSeconds;
var isWarning = ageSeconds >= budget.WarningSeconds;
return new StalenessEvaluation(ageSeconds, budget.WarningSeconds, budget.BreachSeconds, isWarning, isBreach);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Loads time anchors from hex-encoded fixtures or bundle payloads and validates basic structure.
/// Cryptographic verification is still stubbed; this keeps ingestion deterministic for offline testing.
/// </summary>
public sealed class TimeAnchorLoader
{
private readonly TimeVerificationService _verification;
public TimeAnchorLoader()
{
_verification = new TimeVerificationService();
}
public TimeAnchorValidationResult TryLoadHex(string hex, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (string.IsNullOrWhiteSpace(hex))
{
return TimeAnchorValidationResult.Failure("token-empty");
}
try
{
var bytes = Convert.FromHexString(hex.Trim());
return _verification.Verify(bytes, format, trustRoots, out anchor);
}
catch (FormatException)
{
return TimeAnchorValidationResult.Failure("token-hex-invalid");
}
}
}

View File

@@ -0,0 +1,32 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Stores;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Provides current time-anchor status (anchor + staleness) per tenant.
/// </summary>
public sealed class TimeStatusService
{
private readonly ITimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator)
{
_store = store;
_calculator = calculator;
}
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
{
budget.Validate();
await _store.SetAsync(tenantId, anchor, budget, cancellationToken);
}
public async Task<TimeStatus> GetStatusAsync(string tenantId, DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
{
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
return new TimeStatus(anchor, eval, budget, nowUtc);
}
}

View File

@@ -0,0 +1,26 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class TimeVerificationService
{
private readonly IReadOnlyDictionary<TimeTokenFormat, ITimeTokenVerifier> _verifiers;
public TimeVerificationService()
{
var verifiers = new ITimeTokenVerifier[] { new RoughtimeVerifier(), new Rfc3161Verifier() };
_verifiers = verifiers.ToDictionary(v => v.Format, v => v);
}
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (!_verifiers.TryGetValue(format, out var verifier))
{
return TimeAnchorValidationResult.Failure("unknown-format");
}
return verifier.Verify(tokenBytes, trustRoots, out anchor);
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Time</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Stores;
public interface ITimeAnchorStore
{
Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken);
Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,25 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Stores;
public sealed class InMemoryTimeAnchorStore : ITimeAnchorStore
{
private readonly Dictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.Ordinal);
public Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_anchors[tenantId] = (anchor, budget);
return Task.CompletedTask;
}
public Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_anchors.TryGetValue(tenantId, out var value))
{
return Task.FromResult(value);
}
return Task.FromResult((TimeAnchor.Unknown, StalenessBudget.Default));
}
}

View File

@@ -0,0 +1 @@
308201223081c9a0030201020404c78a5540300d06092a864886f70d01010b0500300d310b3009060355040313025441301e170d3233313132303130303030305a170d3234313132393130303030305a300d310b300906035504031302544130820122300d06092a864886f70d01010105000382010f003082010a0282010100c3e8c4a1b2f7f6...

View File

@@ -0,0 +1 @@
0102030473616d706c652d726f75676874696d652d746f6b656e00