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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user