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

@@ -7,5 +7,10 @@ public sealed record TimeStatus(
IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
DateTimeOffset EvaluatedAtUtc)
{
/// <summary>
/// Indicates whether a valid time anchor is present.
/// </summary>
public bool HasAnchor => Anchor != TimeAnchor.Unknown && Anchor.AnchorTime > DateTimeOffset.MinValue;
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, new Dictionary<string, StalenessEvaluation>(), DateTimeOffset.UnixEpoch);
}

View File

@@ -9,6 +9,7 @@ using StellaOps.AirGap.Time.Parsing;
var builder = WebApplication.CreateBuilder(args);
// Core services
builder.Services.AddSingleton<StalenessCalculator>();
builder.Services.AddSingleton<TimeTelemetry>();
builder.Services.AddSingleton<TimeStatusService>();
@@ -18,6 +19,12 @@ builder.Services.AddSingleton<TimeAnchorLoader>();
builder.Services.AddSingleton<TimeTokenParser>();
builder.Services.AddSingleton<SealedStartupValidator>();
builder.Services.AddSingleton<TrustRootProvider>();
// AIRGAP-TIME-57-001: Time-anchor policy service
builder.Services.Configure<TimeAnchorPolicyOptions>(builder.Configuration.GetSection("AirGap:Policy"));
builder.Services.AddSingleton<ITimeAnchorPolicyService, TimeAnchorPolicyService>();
// Configuration and validation
builder.Services.Configure<AirGapOptions>(builder.Configuration.GetSection("AirGap"));
builder.Services.AddSingleton<IValidateOptions<AirGapOptions>, AirGapOptionsValidator>();
builder.Services.AddHealthChecks().AddCheck<TimeAnchorHealthCheck>("time_anchor");

View File

@@ -1,32 +1,218 @@
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Verifies RFC 3161 timestamp tokens using SignedCms and X509 certificate chain validation.
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
/// </summary>
public sealed class Rfc3161Verifier : ITimeTokenVerifier
{
// RFC 3161 OIDs
private static readonly Oid TstInfoOid = new("1.2.840.113549.1.9.16.1.4"); // id-ct-TSTInfo
private static readonly Oid SigningTimeOid = new("1.2.840.113549.1.9.5");
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");
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
}
// 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");
// Compute token digest for reference
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
try
{
// Parse the SignedCms structure
var signedCms = new SignedCms();
signedCms.Decode(tokenBytes.ToArray());
// Verify signature (basic check without chain building)
try
{
signedCms.CheckSignature(verifySignatureOnly: true);
}
catch (CryptographicException ex)
{
return TimeAnchorValidationResult.Failure($"rfc3161-signature-invalid:{ex.Message}");
}
// Extract the signing certificate
if (signedCms.SignerInfos.Count == 0)
{
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
}
var signerInfo = signedCms.SignerInfos[0];
var signerCert = signerInfo.Certificate;
if (signerCert is null)
{
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
}
// Validate signer certificate against trust roots
var validRoot = ValidateAgainstTrustRoots(signerCert, trustRoots);
if (validRoot is null)
{
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
}
// Extract signing time from the TSTInfo or signed attributes
var signingTime = ExtractSigningTime(signedCms, signerInfo);
if (signingTime is null)
{
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
}
// Compute certificate fingerprint
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
anchor = new TimeAnchor(
signingTime.Value,
$"rfc3161:{validRoot.KeyId}",
"RFC3161",
certFingerprint,
tokenDigest);
return TimeAnchorValidationResult.Success("rfc3161-verified");
}
catch (CryptographicException ex)
{
return TimeAnchorValidationResult.Failure($"rfc3161-decode-error:{ex.Message}");
}
catch (Exception ex)
{
return TimeAnchorValidationResult.Failure($"rfc3161-error:{ex.Message}");
}
}
private static TimeTrustRoot? ValidateAgainstTrustRoots(X509Certificate2 signerCert, IReadOnlyList<TimeTrustRoot> trustRoots)
{
foreach (var root in trustRoots)
{
// Match by certificate thumbprint or subject key identifier
try
{
// Try direct certificate match
var rootCert = X509CertificateLoader.LoadCertificate(root.PublicKey);
if (signerCert.Thumbprint.Equals(rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
{
return root;
}
// Try chain validation against root
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
if (chain.Build(signerCert))
{
return root;
}
}
catch
{
// Invalid root certificate format, try next
continue;
}
}
return null;
}
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
{
// Try to get signing time from signed attributes
foreach (var attr in signerInfo.SignedAttributes)
{
if (attr.Oid.Value == SigningTimeOid.Value)
{
try
{
var reader = new AsnReader(attr.Values[0].RawData, AsnEncodingRules.DER);
var time = reader.ReadUtcTime();
return time;
}
catch
{
continue;
}
}
}
// Try to extract from TSTInfo content
try
{
var content = signedCms.ContentInfo;
if (content.ContentType.Value == TstInfoOid.Value)
{
var tstInfo = ParseTstInfo(content.Content);
if (tstInfo.HasValue)
{
return tstInfo.Value;
}
}
}
catch
{
// Fall through
}
return null;
}
private static DateTimeOffset? ParseTstInfo(ReadOnlyMemory<byte> tstInfoBytes)
{
// TSTInfo ::= SEQUENCE {
// version INTEGER,
// policy OBJECT IDENTIFIER,
// messageImprint MessageImprint,
// serialNumber INTEGER,
// genTime GeneralizedTime,
// ...
// }
try
{
var reader = new AsnReader(tstInfoBytes, AsnEncodingRules.DER);
var sequenceReader = reader.ReadSequence();
// Skip version
sequenceReader.ReadInteger();
// Skip policy OID
sequenceReader.ReadObjectIdentifier();
// Skip messageImprint (SEQUENCE)
sequenceReader.ReadSequence();
// Skip serialNumber
sequenceReader.ReadInteger();
// Read genTime (GeneralizedTime)
var genTime = sequenceReader.ReadGeneralizedTime();
return genTime;
}
catch
{
return null;
}
}
}

View File

@@ -1,32 +1,350 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Verifies Roughtime tokens using Ed25519 signature verification.
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
/// </summary>
public sealed class RoughtimeVerifier : ITimeTokenVerifier
{
// Roughtime wire format tag constants (32-bit little-endian ASCII codes)
private const uint TagSig = 0x00474953; // "SIG\0" - Signature
private const uint TagMidp = 0x5044494D; // "MIDP" - Midpoint
private const uint TagRadi = 0x49444152; // "RADI" - Radius
private const uint TagRoot = 0x544F4F52; // "ROOT" - Merkle root
private const uint TagPath = 0x48544150; // "PATH" - Merkle path
private const uint TagIndx = 0x58444E49; // "INDX" - Index
private const uint TagSrep = 0x50455253; // "SREP" - Signed response
// Ed25519 constants
private const int Ed25519SignatureLength = 64;
private const int Ed25519PublicKeyLength = 32;
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");
return TimeAnchorValidationResult.Failure("roughtime-trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
return TimeAnchorValidationResult.Failure("roughtime-token-empty");
}
// 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");
// Compute token digest for reference
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
// Parse Roughtime wire format
var parseResult = ParseRoughtimeResponse(tokenBytes, out var midpointMicros, out var radiusMicros, out var signature, out var signedMessage);
if (!parseResult.IsValid)
{
return parseResult;
}
// Find a valid trust root with Ed25519 key
TimeTrustRoot? validRoot = null;
foreach (var root in trustRoots)
{
if (!string.Equals(root.Algorithm, "ed25519", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (root.PublicKey.Length != Ed25519PublicKeyLength)
{
continue;
}
// Verify Ed25519 signature
if (VerifyEd25519Signature(signedMessage, signature, root.PublicKey))
{
validRoot = root;
break;
}
}
if (validRoot is null)
{
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
}
// Convert midpoint from microseconds to DateTimeOffset
var anchorTime = DateTimeOffset.UnixEpoch.AddMicroseconds(midpointMicros);
// Compute signature fingerprint from the public key
var keyFingerprint = Convert.ToHexString(SHA256.HashData(validRoot.PublicKey)).ToLowerInvariant()[..16];
anchor = new TimeAnchor(
anchorTime,
$"roughtime:{validRoot.KeyId}",
"Roughtime",
keyFingerprint,
tokenDigest);
return TimeAnchorValidationResult.Success($"roughtime-verified:radius={radiusMicros}us");
}
private static TimeAnchorValidationResult ParseRoughtimeResponse(
ReadOnlySpan<byte> data,
out long midpointMicros,
out uint radiusMicros,
out ReadOnlySpan<byte> signature,
out ReadOnlySpan<byte> signedMessage)
{
midpointMicros = 0;
radiusMicros = 0;
signature = ReadOnlySpan<byte>.Empty;
signedMessage = ReadOnlySpan<byte>.Empty;
// Roughtime wire format: [num_tags:u32] [offsets:u32[]] [tags:u32[]] [values...]
// Minimum size: 4 (num_tags) + at least one tag
if (data.Length < 8)
{
return TimeAnchorValidationResult.Failure("roughtime-message-too-short");
}
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
if (numTags == 0 || numTags > 100)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-tag-count");
}
// Header size: 4 + 4*(numTags-1) offsets + 4*numTags tags
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
if (data.Length < headerSize)
{
return TimeAnchorValidationResult.Failure("roughtime-header-incomplete");
}
// Parse tags and extract required fields
var offsetsStart = 4;
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
var valuesStart = headerSize;
ReadOnlySpan<byte> sigBytes = ReadOnlySpan<byte>.Empty;
ReadOnlySpan<byte> srepBytes = ReadOnlySpan<byte>.Empty;
for (var i = 0; i < (int)numTags; i++)
{
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
// Calculate value bounds
var valueStart = valuesStart;
var valueEnd = data.Length;
if (i > 0)
{
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
}
if (i < (int)numTags - 1)
{
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
}
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-value-bounds");
}
var value = data.Slice(valueStart, valueEnd - valueStart);
switch (tag)
{
case TagSig:
if (value.Length != Ed25519SignatureLength)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-signature-length");
}
sigBytes = value;
break;
case TagSrep:
srepBytes = value;
break;
}
}
if (sigBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-signature");
}
if (srepBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-srep");
}
// Parse SREP (signed response) for MIDP and RADI
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros);
if (!srepResult.IsValid)
{
return srepResult;
}
signature = sigBytes;
signedMessage = srepBytes;
return TimeAnchorValidationResult.Success("roughtime-parsed");
}
private static TimeAnchorValidationResult ParseSignedResponse(
ReadOnlySpan<byte> data,
out long midpointMicros,
out uint radiusMicros)
{
midpointMicros = 0;
radiusMicros = 0;
if (data.Length < 8)
{
return TimeAnchorValidationResult.Failure("roughtime-srep-too-short");
}
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
if (numTags == 0 || numTags > 50)
{
return TimeAnchorValidationResult.Failure("roughtime-srep-invalid-tag-count");
}
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
if (data.Length < headerSize)
{
return TimeAnchorValidationResult.Failure("roughtime-srep-header-incomplete");
}
var offsetsStart = 4;
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
var valuesStart = headerSize;
var hasMidp = false;
var hasRadi = false;
for (var i = 0; i < (int)numTags; i++)
{
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
var valueStart = valuesStart;
var valueEnd = data.Length;
if (i > 0)
{
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
}
if (i < (int)numTags - 1)
{
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
}
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
{
continue;
}
var value = data.Slice(valueStart, valueEnd - valueStart);
switch (tag)
{
case TagMidp:
if (value.Length == 8)
{
midpointMicros = BinaryPrimitives.ReadInt64LittleEndian(value);
hasMidp = true;
}
break;
case TagRadi:
if (value.Length == 4)
{
radiusMicros = BinaryPrimitives.ReadUInt32LittleEndian(value);
hasRadi = true;
}
break;
}
}
if (!hasMidp)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
}
if (!hasRadi)
{
// RADI is optional, default to 1 second uncertainty
radiusMicros = 1_000_000;
}
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
}
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
{
try
{
// Roughtime signs the context-prefixed message: "RoughTime v1 response signature\0" || SREP
const string ContextPrefix = "RoughTime v1 response signature\0";
var prefixBytes = System.Text.Encoding.ASCII.GetBytes(ContextPrefix);
var signedData = new byte[prefixBytes.Length + message.Length];
prefixBytes.CopyTo(signedData, 0);
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
using var ed25519 = ECDiffieHellman.Create(ECCurve.CreateFromFriendlyName("curve25519"));
// Use .NET's Ed25519 verification
// Note: .NET 10 supports Ed25519 natively via ECDsa with curve Ed25519
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
}
catch
{
return false;
}
}
}
/// <summary>
/// Ed25519 signature verification helper using .NET cryptography.
/// </summary>
internal static class Ed25519
{
public static bool Verify(byte[] publicKey, byte[] message, byte[] signature)
{
try
{
// .NET 10 has native Ed25519 support via ECDsa
using var ecdsa = ECDsa.Create(ECCurve.CreateFromValue("1.3.101.112")); // Ed25519 OID
ecdsa.ImportSubjectPublicKeyInfo(CreateEd25519Spki(publicKey), out _);
return ecdsa.VerifyData(message, signature, HashAlgorithmName.SHA512);
}
catch
{
// Fallback: if Ed25519 curve not available, return false
return false;
}
}
private static byte[] CreateEd25519Spki(byte[] publicKey)
{
// Ed25519 SPKI format:
// 30 2a - SEQUENCE (42 bytes)
// 30 05 - SEQUENCE (5 bytes)
// 06 03 2b 65 70 - OID 1.3.101.112 (Ed25519)
// 03 21 00 [32 bytes public key]
var spki = new byte[44];
new byte[] { 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 }.CopyTo(spki, 0);
publicKey.CopyTo(spki, 12);
return spki;
}
}

View File

@@ -0,0 +1,306 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Policy enforcement service for time anchors.
/// Per AIRGAP-TIME-57-001: Enforces time-anchor requirements in sealed-mode operations.
/// </summary>
public interface ITimeAnchorPolicyService
{
/// <summary>
/// Validates that a valid time anchor exists and is not stale.
/// </summary>
Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Enforces time-anchor requirements before bundle import.
/// </summary>
Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
string tenantId,
string bundleId,
DateTimeOffset? bundleTimestamp,
CancellationToken cancellationToken = default);
/// <summary>
/// Enforces time-anchor requirements before operations that require trusted time.
/// </summary>
Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
string tenantId,
string operation,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the time drift between the anchor and a given timestamp.
/// </summary>
Task<TimeAnchorDriftResult> CalculateDriftAsync(
string tenantId,
DateTimeOffset targetTime,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of time-anchor policy evaluation.
/// </summary>
public sealed record TimeAnchorPolicyResult(
bool Allowed,
string? ErrorCode,
string? Reason,
string? Remediation,
StalenessEvaluation? Staleness);
/// <summary>
/// Result of time drift calculation.
/// </summary>
public sealed record TimeAnchorDriftResult(
bool HasAnchor,
TimeSpan Drift,
bool DriftExceedsThreshold,
DateTimeOffset? AnchorTime);
/// <summary>
/// Policy configuration for time anchors.
/// </summary>
public sealed class TimeAnchorPolicyOptions
{
/// <summary>
/// Whether to enforce strict time-anchor requirements.
/// When true, operations fail if time anchor is missing or stale.
/// </summary>
public bool StrictEnforcement { get; set; } = true;
/// <summary>
/// Maximum allowed drift between anchor time and operation time in seconds.
/// </summary>
public int MaxDriftSeconds { get; set; } = 86400; // 24 hours
/// <summary>
/// Whether to allow operations when no time anchor exists (unsealed mode only).
/// </summary>
public bool AllowMissingAnchorInUnsealedMode { get; set; } = true;
/// <summary>
/// Operations that require strict time-anchor enforcement regardless of mode.
/// </summary>
public IReadOnlyList<string> StrictOperations { get; set; } = new[]
{
"bundle.import",
"attestation.sign",
"audit.record"
};
}
/// <summary>
/// Error codes for time-anchor policy violations.
/// </summary>
public static class TimeAnchorPolicyErrorCodes
{
public const string AnchorMissing = "TIME_ANCHOR_MISSING";
public const string AnchorStale = "TIME_ANCHOR_STALE";
public const string AnchorBreached = "TIME_ANCHOR_BREACHED";
public const string DriftExceeded = "TIME_ANCHOR_DRIFT_EXCEEDED";
public const string PolicyViolation = "TIME_ANCHOR_POLICY_VIOLATION";
}
/// <summary>
/// Implementation of time-anchor policy service.
/// </summary>
public sealed class TimeAnchorPolicyService : ITimeAnchorPolicyService
{
private readonly TimeStatusService _statusService;
private readonly TimeAnchorPolicyOptions _options;
private readonly ILogger<TimeAnchorPolicyService> _logger;
private readonly TimeProvider _timeProvider;
public TimeAnchorPolicyService(
TimeStatusService statusService,
IOptions<TimeAnchorPolicyOptions> options,
ILogger<TimeAnchorPolicyService> logger,
TimeProvider? timeProvider = null)
{
_statusService = statusService ?? throw new ArgumentNullException(nameof(statusService));
_options = options?.Value ?? new TimeAnchorPolicyOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
// Check if anchor exists
if (!status.HasAnchor)
{
if (_options.AllowMissingAnchorInUnsealedMode && !_options.StrictEnforcement)
{
_logger.LogDebug("Time anchor missing for tenant {TenantId}, allowed in non-strict mode", tenantId);
return new TimeAnchorPolicyResult(
Allowed: true,
ErrorCode: null,
Reason: "time-anchor-missing-allowed",
Remediation: null,
Staleness: null);
}
_logger.LogWarning("Time anchor missing for tenant {TenantId} [{ErrorCode}]",
tenantId, TimeAnchorPolicyErrorCodes.AnchorMissing);
return new TimeAnchorPolicyResult(
Allowed: false,
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorMissing,
Reason: "No time anchor configured for tenant",
Remediation: "Set a time anchor using POST /api/v1/time/anchor with a valid Roughtime or RFC3161 token",
Staleness: null);
}
// Evaluate staleness
var staleness = status.Staleness;
// Check for breach
if (staleness.IsBreach)
{
_logger.LogWarning(
"Time anchor staleness breached for tenant {TenantId}: age={AgeSeconds}s > breach={BreachSeconds}s [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorBreached);
return new TimeAnchorPolicyResult(
Allowed: false,
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorBreached,
Reason: $"Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s)",
Remediation: "Refresh time anchor with a new token to continue operations",
Staleness: staleness);
}
// Check for warning (allowed but logged)
if (staleness.IsWarning)
{
_logger.LogWarning(
"Time anchor staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorStale);
}
return new TimeAnchorPolicyResult(
Allowed: true,
ErrorCode: null,
Reason: staleness.IsWarning ? "time-anchor-warning" : "time-anchor-valid",
Remediation: staleness.IsWarning ? "Consider refreshing time anchor soon" : null,
Staleness: staleness);
}
public async Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
string tenantId,
string bundleId,
DateTimeOffset? bundleTimestamp,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
// First validate basic time anchor requirements
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!baseResult.Allowed)
{
return baseResult;
}
// If bundle has a timestamp, check drift
if (bundleTimestamp.HasValue)
{
var driftResult = await CalculateDriftAsync(tenantId, bundleTimestamp.Value, cancellationToken).ConfigureAwait(false);
if (driftResult.DriftExceedsThreshold)
{
_logger.LogWarning(
"Bundle {BundleId} timestamp drift exceeds threshold for tenant {TenantId}: drift={DriftSeconds}s > max={MaxDriftSeconds}s [{ErrorCode}]",
bundleId, tenantId, driftResult.Drift.TotalSeconds, _options.MaxDriftSeconds, TimeAnchorPolicyErrorCodes.DriftExceeded);
return new TimeAnchorPolicyResult(
Allowed: false,
ErrorCode: TimeAnchorPolicyErrorCodes.DriftExceeded,
Reason: $"Bundle timestamp drift exceeds maximum ({driftResult.Drift.TotalSeconds:F0}s > {_options.MaxDriftSeconds}s)",
Remediation: "Bundle is too old or time anchor is significantly out of sync. Refresh the time anchor or use a more recent bundle.",
Staleness: baseResult.Staleness);
}
}
_logger.LogDebug("Bundle import policy passed for tenant {TenantId}, bundle {BundleId}", tenantId, bundleId);
return baseResult;
}
public async Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
string tenantId,
string operation,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
var isStrictOperation = _options.StrictOperations.Contains(operation, StringComparer.OrdinalIgnoreCase);
// For strict operations, always require valid time anchor
if (isStrictOperation)
{
var result = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!result.Allowed)
{
_logger.LogWarning(
"Strict operation {Operation} blocked for tenant {TenantId}: {Reason} [{ErrorCode}]",
operation, tenantId, result.Reason, result.ErrorCode);
}
return result;
}
// For non-strict operations, allow with warning if anchor is missing/stale
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!baseResult.Allowed && !_options.StrictEnforcement)
{
_logger.LogDebug(
"Non-strict operation {Operation} allowed for tenant {TenantId} despite policy issue: {Reason}",
operation, tenantId, baseResult.Reason);
return new TimeAnchorPolicyResult(
Allowed: true,
ErrorCode: baseResult.ErrorCode,
Reason: $"operation-allowed-with-warning:{baseResult.Reason}",
Remediation: baseResult.Remediation,
Staleness: baseResult.Staleness);
}
return baseResult;
}
public async Task<TimeAnchorDriftResult> CalculateDriftAsync(
string tenantId,
DateTimeOffset targetTime,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
if (!status.HasAnchor)
{
return new TimeAnchorDriftResult(
HasAnchor: false,
Drift: TimeSpan.Zero,
DriftExceedsThreshold: false,
AnchorTime: null);
}
var drift = targetTime - status.Anchor!.AnchorTime;
var absDriftSeconds = Math.Abs(drift.TotalSeconds);
var exceedsThreshold = absDriftSeconds > _options.MaxDriftSeconds;
return new TimeAnchorDriftResult(
HasAnchor: true,
Drift: drift,
DriftExceedsThreshold: exceedsThreshold,
AnchorTime: status.Anchor.AnchorTime);
}
}

View File

@@ -5,6 +5,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Time</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- AIRGAP-TIME-57-001: RFC3161 verification requires PKCS support -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>