Files
git.stella-ops.org/src/AirGap/StellaOps.AirGap.Time/Services/Rfc3161Verifier.cs

340 lines
10 KiB
C#

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,
TimeTokenVerificationOptions? options = null)
{
anchor = TimeAnchor.Unknown;
if (trustRoots.Count == 0)
{
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
}
// 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");
}
// 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");
}
// Validate signer certificate against trust roots
var extraCertificates = BuildExtraCertificates(signedCms, options);
var verificationTime = options?.VerificationTime ?? signingTime.Value;
var validRoot = ValidateAgainstTrustRoots(
signerCert,
trustRoots,
extraCertificates,
verificationTime);
if (validRoot is null)
{
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
}
if (options?.Offline == true)
{
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
{
return TimeAnchorValidationResult.Failure(revocationReason);
}
}
// 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,
IReadOnlyList<X509Certificate2> extraCertificates,
DateTimeOffset verificationTime)
{
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;
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
foreach (var cert in extraCertificates)
{
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
{
chain.ChainPolicy.ExtraStore.Add(cert);
}
}
if (chain.Build(signerCert))
{
return root;
}
}
catch
{
// Invalid root certificate format, try next
continue;
}
}
return null;
}
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
SignedCms signedCms,
TimeTokenVerificationOptions? options)
{
var extra = new List<X509Certificate2>();
if (options?.CertificateChain is { Count: > 0 })
{
extra.AddRange(options.CertificateChain);
}
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
{
if (!extra.Any(existing =>
existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
{
extra.Add(cert);
}
}
return extra;
}
private static bool TryVerifyOfflineRevocation(
TimeTokenVerificationOptions options,
out string reason)
{
var hasOcsp = options.OcspResponses.Count > 0;
var hasCrl = options.Crls.Count > 0;
if (!hasOcsp && !hasCrl)
{
reason = "rfc3161-revocation-missing";
return false;
}
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
{
reason = "rfc3161-revocation-ocsp";
return true;
}
if (hasCrl && options.Crls.Any(IsCrlParseable))
{
reason = "rfc3161-revocation-crl";
return true;
}
reason = "rfc3161-revocation-invalid";
return false;
}
private static bool IsOcspSuccess(byte[] response)
{
try
{
var reader = new AsnReader(response, AsnEncodingRules.DER);
var sequence = reader.ReadSequence();
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
return status == OcspResponseStatus.Successful;
}
catch
{
return false;
}
}
private static bool IsCrlParseable(byte[] crl)
{
try
{
var reader = new AsnReader(crl, AsnEncodingRules.DER);
reader.ReadSequence();
return true;
}
catch
{
return false;
}
}
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;
}
}
private enum OcspResponseStatus
{
Successful = 0,
MalformedRequest = 1,
InternalError = 2,
TryLater = 3,
SigRequired = 5,
Unauthorized = 6
}
}