340 lines
10 KiB
C#
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
|
|
}
|
|
}
|