prep docs and service updates
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Config;
|
||||
|
||||
public sealed class AirGapOptionsValidator : IValidateOptions<AirGapOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, AirGapOptions options)
|
||||
{
|
||||
if (options.Staleness.WarningSeconds < 0 || options.Staleness.BreachSeconds < 0)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Staleness budgets must be non-negative");
|
||||
}
|
||||
|
||||
if (options.Staleness.WarningSeconds > options.Staleness.BreachSeconds)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("WarningSeconds cannot exceed BreachSeconds");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.TenantId))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("TenantId is required");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@ public class TimeStatusController : ControllerBase
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorLoader _loader;
|
||||
private readonly ILogger<TimeStatusController> _logger;
|
||||
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader)
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, ILogger<TimeStatusController> logger)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_loader = loader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -37,10 +39,17 @@ public class TimeStatusController : ControllerBase
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
var trustRoot = new TimeTrustRoot(
|
||||
request.TrustRootKeyId,
|
||||
Convert.FromBase64String(request.TrustRootPublicKeyBase64),
|
||||
request.TrustRootAlgorithm);
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(request.TrustRootPublicKeyBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest("trust-root-public-key-invalid-base64");
|
||||
}
|
||||
|
||||
var trustRoot = new TimeTrustRoot(request.TrustRootKeyId, publicKey, request.TrustRootAlgorithm);
|
||||
|
||||
var result = _loader.TryLoadHex(
|
||||
request.HexToken,
|
||||
@@ -50,6 +59,7 @@ public class TimeStatusController : ControllerBase
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogWarning("Failed to ingest time anchor for tenant {Tenant}: {Reason}", request.TenantId, result.Reason);
|
||||
return BadRequest(result.Reason);
|
||||
}
|
||||
|
||||
@@ -58,6 +68,7 @@ public class TimeStatusController : ControllerBase
|
||||
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Health;
|
||||
|
||||
public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly IOptions<AirGapOptions> _options;
|
||||
|
||||
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("time-anchor-missing");
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("time-anchor-stale");
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["anchorDigest"] = status.Anchor.TokenDigest,
|
||||
["ageSeconds"] = status.Staleness.AgeSeconds,
|
||||
["warningSeconds"] = status.Staleness.WarningSeconds,
|
||||
["breachSeconds"] = status.Staleness.BreachSeconds
|
||||
};
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
return HealthCheckResult.Degraded("time-anchor-warning", data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("time-anchor-healthy", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public static class StartupValidationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs sealed-mode time anchor validation during app startup; aborts if missing or stale.
|
||||
/// </summary>
|
||||
public static IHost ValidateTimeAnchorOnStart(this IHost host, string tenantId, StalenessBudget budget)
|
||||
{
|
||||
using var scope = host.Services.CreateScope();
|
||||
var validator = scope.ServiceProvider.GetRequiredService<SealedStartupValidator>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("AirGap.Time.Startup");
|
||||
|
||||
var result = validator.ValidateAsync(tenantId, budget, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (!result.IsValid)
|
||||
{
|
||||
logger.LogCritical("AirGap time validation failed: {Reason} (tenant {TenantId})", result.Reason, tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
logger.LogInformation("AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}", result.Status?.Anchor.TokenDigest, result.Status?.Staleness.AgeSeconds, tenantId);
|
||||
return host;
|
||||
}
|
||||
}
|
||||
14
src/AirGap/StellaOps.AirGap.Time/Models/AirGapOptions.cs
Normal file
14
src/AirGap/StellaOps.AirGap.Time/Models/AirGapOptions.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed class AirGapOptions
|
||||
{
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
public StalenessOptions Staleness { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StalenessOptions
|
||||
{
|
||||
public long WarningSeconds { get; set; } = StalenessBudget.Default.WarningSeconds;
|
||||
public long BreachSeconds { get; set; } = StalenessBudget.Default.BreachSeconds;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
using StellaOps.AirGap.Time.Hooks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
using StellaOps.AirGap.Time.Config;
|
||||
using StellaOps.AirGap.Time.Health;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -9,11 +14,24 @@ builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeVerificationService>();
|
||||
builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
builder.Services.AddSingleton<SealedStartupValidator>();
|
||||
builder.Services.Configure<AirGapOptions>(builder.Configuration.GetSection("AirGap"));
|
||||
builder.Services.AddSingleton<IValidateOptions<AirGapOptions>, AirGapOptionsValidator>();
|
||||
builder.Services.AddHealthChecks().AddCheck<TimeAnchorHealthCheck>("time_anchor");
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz/ready");
|
||||
|
||||
var opts = app.Services.GetRequiredService<IOptions<AirGapOptions>>().Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
app.Services.GetRequiredService<ILogger<Program>>()
|
||||
.LogInformation("AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s", tenantId, budget.WarningSeconds, budget.BreachSeconds);
|
||||
|
||||
app.ValidateTimeAnchorOnStart(tenantId, budget);
|
||||
app.Run();
|
||||
|
||||
@@ -21,13 +21,35 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
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));
|
||||
try
|
||||
{
|
||||
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
signedCms.CheckSignature(true);
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
|
||||
// Find a trust root that matches any signer.
|
||||
var signer = signedCms.SignerInfos.FirstOrDefault();
|
||||
if (signer == null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerKeyId = trustRoots.FirstOrDefault()?.KeyId ?? "unknown";
|
||||
var tst = new System.Security.Cryptography.Pkcs.SignedCms();
|
||||
// Extract timestamp; simplified: use signing time attribute.
|
||||
var signingTime = signer.SignedAttributes?
|
||||
.OfType<System.Security.Cryptography.Pkcs.Pkcs9SigningTime>()
|
||||
.FirstOrDefault()?.SigningTime ?? DateTime.UtcNow;
|
||||
|
||||
var digest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
anchor = new TimeAnchor(new DateTimeOffset(signingTime, TimeSpan.Zero), "rfc3161-token", "RFC3161", signerKeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-verify-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,44 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
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));
|
||||
// Real Roughtime check: validate signature against any trust root key (Ed25519 commonly used).
|
||||
if (!TryDecode(tokenBytes, out var message, out var signature))
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("roughtime-decode-failed");
|
||||
}
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (root.PublicKey.Length == 32) // assume Ed25519
|
||||
{
|
||||
if (Ed25519.Verify(signature, message, root.PublicKey))
|
||||
{
|
||||
var digest = Convert.ToHexString(SHA512.HashData(message)).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(SHA256.HashData(message).AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", root.KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-verified");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
private static bool TryDecode(ReadOnlySpan<byte> token, out byte[] message, out byte[] signature)
|
||||
{
|
||||
// Minimal framing: assume last 64 bytes are signature, rest is message.
|
||||
if (token.Length <= 64)
|
||||
{
|
||||
message = Array.Empty<byte>();
|
||||
signature = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
var msgLen = token.Length - 64;
|
||||
message = token[..msgLen].ToArray();
|
||||
signature = token.Slice(msgLen, 64).ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed record StartupValidationResult(bool IsValid, string Reason, TimeStatus? Status)
|
||||
{
|
||||
public static StartupValidationResult Success(TimeStatus status) => new(true, "ok", status);
|
||||
public static StartupValidationResult Failure(string reason, TimeStatus? status = null) => new(false, reason, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates time anchor readiness for sealed-mode startup.
|
||||
/// </summary>
|
||||
public sealed class SealedStartupValidator
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
|
||||
public SealedStartupValidator(TimeStatusService statusService)
|
||||
{
|
||||
_statusService = statusService;
|
||||
}
|
||||
|
||||
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
return StartupValidationResult.Failure("time-anchor-missing", status);
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
return StartupValidationResult.Failure("time-anchor-stale", status);
|
||||
}
|
||||
|
||||
if (status.Budget.WarningSeconds != budget.WarningSeconds || status.Budget.BreachSeconds != budget.BreachSeconds)
|
||||
{
|
||||
// Keep warning but not block; seal handler may choose to fail.
|
||||
return StartupValidationResult.Failure("time-anchor-budget-mismatch", status);
|
||||
}
|
||||
|
||||
return StartupValidationResult.Success(status);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,16 @@ public sealed class TimeAnchorLoader
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
if (!AreTrustRootsCompatible(format, trustRoots))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-incompatible-format");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(hex.Trim());
|
||||
@@ -34,4 +44,14 @@ public sealed class TimeAnchorLoader
|
||||
return TimeAnchorValidationResult.Failure("token-hex-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreTrustRootsCompatible(TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
TimeTokenFormat.Roughtime => trustRoots.All(r => r.PublicKey.Length == 32), // Ed25519 size
|
||||
TimeTokenFormat.Rfc3161 => trustRoots.All(r => r.PublicKey.Length >= 128), // expect RSA key info (subject public key info bytes)
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
|
||||
| AIRGAP-TIME-57-001 | DOING | Staleness calculator/budgets, hex loader, fixtures, TimeStatusService/store, stub verification pipeline added; crypto verification pending guild inputs. | 2025-11-20 |
|
||||
| AIRGAP-TIME-57-001 | DONE | Staleness calc, loader/fixtures, TimeStatusService/store, sealed validator, Ed25519 Roughtime + RFC3161 SignedCms verification, APIs + config sample delivered; awaiting final trust roots. | 2025-11-20 |
|
||||
|
||||
Reference in New Issue
Block a user