This commit is contained in:
@@ -22,6 +22,11 @@ public sealed class AirGapOptionsValidator : IValidateOptions<AirGapOptions>
|
||||
return ValidateOptionsResult.Fail("TenantId is required");
|
||||
}
|
||||
|
||||
if (options.AllowUntrustedAnchors)
|
||||
{
|
||||
// no-op; explicitly allowed for offline testing
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ public class TimeStatusController : ControllerBase
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorLoader _loader;
|
||||
private readonly TrustRootProvider _trustRoots;
|
||||
private readonly ILogger<TimeStatusController> _logger;
|
||||
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, ILogger<TimeStatusController> logger)
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, TrustRootProvider trustRoots, ILogger<TimeStatusController> logger)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_loader = loader;
|
||||
_trustRoots = trustRoots;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -39,22 +41,24 @@ public class TimeStatusController : ControllerBase
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
byte[] publicKey;
|
||||
try
|
||||
var trustRoots = _trustRoots.GetAll();
|
||||
if (!string.IsNullOrWhiteSpace(request.TrustRootPublicKeyBase64))
|
||||
{
|
||||
publicKey = Convert.FromBase64String(request.TrustRootPublicKeyBase64);
|
||||
try
|
||||
{
|
||||
var publicKey = Convert.FromBase64String(request.TrustRootPublicKeyBase64);
|
||||
trustRoots = new[] { new TimeTrustRoot(request.TrustRootKeyId, publicKey, request.TrustRootAlgorithm) };
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest("trust-root-public-key-invalid-base64");
|
||||
}
|
||||
}
|
||||
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,
|
||||
request.Format,
|
||||
new[] { trustRoot },
|
||||
trustRoots,
|
||||
out var anchor);
|
||||
|
||||
if (!result.IsValid)
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
return HealthCheckResult.Unhealthy("time-anchor-stale");
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
IReadOnlyDictionary<string, object> data = new Dictionary<string, object>
|
||||
{
|
||||
["anchorDigest"] = status.Anchor.TokenDigest,
|
||||
["ageSeconds"] = status.Staleness.AgeSeconds,
|
||||
@@ -41,7 +41,7 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
return HealthCheckResult.Degraded("time-anchor-warning", data);
|
||||
return HealthCheckResult.Degraded("time-anchor-warning", data: data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("time-anchor-healthy", data);
|
||||
|
||||
@@ -5,6 +5,16 @@ public sealed class AirGapOptions
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
public StalenessOptions Staleness { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Path to trust roots bundle (JSON). Used by AirGap Time to validate anchors when supplied.
|
||||
/// </summary>
|
||||
public string TrustRootFile { get; set; } = "docs/airgap/time-anchor-trust-roots.json";
|
||||
|
||||
/// <summary>
|
||||
/// Allow accepting anchors without trust-root verification (for offline testing only).
|
||||
/// </summary>
|
||||
public bool AllowUntrustedAnchors { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class StalenessOptions
|
||||
|
||||
@@ -14,13 +14,10 @@ public sealed class SetAnchorRequest
|
||||
[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; }
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
using StellaOps.AirGap.Time.Config;
|
||||
using StellaOps.AirGap.Time.Health;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -15,6 +16,7 @@ builder.Services.AddSingleton<TimeVerificationService>();
|
||||
builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
builder.Services.AddSingleton<SealedStartupValidator>();
|
||||
builder.Services.AddSingleton<TrustRootProvider>();
|
||||
builder.Services.Configure<AirGapOptions>(builder.Configuration.GetSection("AirGap"));
|
||||
builder.Services.AddSingleton<IValidateOptions<AirGapOptions>, AirGapOptionsValidator>();
|
||||
builder.Services.AddHealthChecks().AddCheck<TimeAnchorHealthCheck>("time_anchor");
|
||||
|
||||
@@ -21,35 +21,12 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
signedCms.CheckSignature(true);
|
||||
|
||||
// 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()}");
|
||||
}
|
||||
// Stub verification: derive anchor deterministically; rely on presence of trust roots for gating.
|
||||
var digest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(SHA256.HashData(tokenBytes).AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
var signerKeyId = trustRoots.FirstOrDefault()?.KeyId ?? "unknown";
|
||||
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", signerKeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,44 +21,12 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
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;
|
||||
// Stub verification: compute digest and derive anchor time deterministically; rely on presence of trust roots.
|
||||
var digest = Convert.ToHexString(SHA512.HashData(tokenBytes)).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(SHA256.HashData(tokenBytes).AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
var root = trustRoots.First();
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", root.KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -10,10 +11,14 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
public sealed class TimeAnchorLoader
|
||||
{
|
||||
private readonly TimeVerificationService _verification;
|
||||
private readonly TimeTokenParser _parser;
|
||||
private readonly bool _allowUntrusted;
|
||||
|
||||
public TimeAnchorLoader()
|
||||
public TimeAnchorLoader(TimeVerificationService verification, TimeTokenParser parser, IOptions<AirGapOptions> options)
|
||||
{
|
||||
_verification = new TimeVerificationService();
|
||||
_verification = verification;
|
||||
_parser = parser;
|
||||
_allowUntrusted = options.Value.AllowUntrustedAnchors;
|
||||
}
|
||||
|
||||
public TimeAnchorValidationResult TryLoadHex(string hex, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
@@ -26,6 +31,22 @@ public sealed class TimeAnchorLoader
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
if (_allowUntrusted)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(hex.Trim());
|
||||
var parsed = _parser.TryParse(bytes, format, out anchor);
|
||||
return parsed.IsValid
|
||||
? TimeAnchorValidationResult.Success("untrusted-no-trust-roots")
|
||||
: parsed;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-hex-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class TrustRootProvider
|
||||
{
|
||||
private readonly IReadOnlyList<TimeTrustRoot> _trustRoots;
|
||||
private readonly ILogger<TrustRootProvider> _logger;
|
||||
|
||||
public TrustRootProvider(IOptions<AirGapOptions> options, ILogger<TrustRootProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var path = options.Value.TrustRootFile;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
_logger.LogWarning("Trust root file not found at {Path}; proceeding with empty trust roots.", path);
|
||||
_trustRoots = Array.Empty<TimeTrustRoot>();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var roots = new List<TimeTrustRoot>();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("roughtime", out var roughtimeArr))
|
||||
{
|
||||
foreach (var item in roughtimeArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString() ?? "unknown-roughtime";
|
||||
var pkB64 = item.GetProperty("publicKeyBase64").GetString() ?? string.Empty;
|
||||
try
|
||||
{
|
||||
var pk = Convert.FromBase64String(pkB64);
|
||||
roots.Add(new TimeTrustRoot(name, pk, "ed25519"));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 public key for roughtime root {Name}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("rfc3161", out var rfcArr))
|
||||
{
|
||||
foreach (var item in rfcArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString() ?? "unknown-rfc3161";
|
||||
var certPem = item.GetProperty("certificatePem").GetString() ?? string.Empty;
|
||||
var normalized = certPem.Replace("-----BEGIN CERTIFICATE-----", string.Empty)
|
||||
.Replace("-----END CERTIFICATE-----", string.Empty)
|
||||
.Replace("\n", string.Empty)
|
||||
.Replace("\r", string.Empty);
|
||||
try
|
||||
{
|
||||
var certBytes = Convert.FromBase64String(normalized);
|
||||
roots.Add(new TimeTrustRoot(name, certBytes, "rfc3161-cert"));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid certificate PEM for RFC3161 root {Name}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_trustRoots = roots;
|
||||
_logger.LogInformation("Loaded {Count} trust roots from {Path}", roots.Count, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load trust roots from {Path}", path);
|
||||
_trustRoots = Array.Empty<TimeTrustRoot>();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<TimeTrustRoot> GetAll() => _trustRoots;
|
||||
}
|
||||
Reference in New Issue
Block a user