Files
git.stella-ops.org/src/Cryptography/StellaOps.Cryptography.Plugin.Eidas/EidasPlugin.cs
2026-02-01 21:37:40 +02:00

519 lines
19 KiB
C#

using Org.BouncyCastle.Security;
using Org.BouncyCastle.X509;
using StellaOps.Cryptography.Plugin.Eidas.Timestamping;
using StellaOps.Plugin.Abstractions;
using StellaOps.Plugin.Abstractions.Capabilities;
using StellaOps.Plugin.Abstractions.Context;
using StellaOps.Plugin.Abstractions.Health;
using StellaOps.Plugin.Abstractions.Lifecycle;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
namespace StellaOps.Cryptography.Plugin.Eidas;
/// <summary>
/// eIDAS cryptography plugin for EU qualified electronic signatures.
/// Implements ETSI TS 119 312 compliant signature operations.
/// </summary>
public sealed class EidasPlugin : CryptoPluginBase
{
private EidasOptions? _options;
private X509Certificate2? _signingCertificate;
private X509Certificate? _bcCertificate;
private RSA? _privateKey;
private ICadesSignatureBuilder? _cadesBuilder;
private ITimestampModeSelector? _timestampModeSelector;
private IQualifiedTimestampVerifier? _timestampVerifier;
private QualifiedTimestampingConfiguration? _timestampingConfiguration;
/// <inheritdoc />
public override PluginInfo Info => new(
Id: "com.stellaops.crypto.eidas",
Name: "eIDAS Cryptography Provider",
Version: "1.0.0",
Vendor: "Stella Ops",
Description: "EU eIDAS qualified electronic signatures (ETSI TS 119 312)",
LicenseId: "BUSL-1.1");
/// <inheritdoc />
public override IReadOnlyList<string> SupportedAlgorithms => new[]
{
"eIDAS-RSA-SHA256",
"eIDAS-RSA-SHA384",
"eIDAS-RSA-SHA512",
"eIDAS-ECDSA-SHA256",
"eIDAS-ECDSA-SHA384",
"eIDAS-CAdES-BES",
"eIDAS-CAdES-T",
"eIDAS-CAdES-LT",
"eIDAS-CAdES-LTA",
"eIDAS-XAdES-BES"
};
/// <inheritdoc />
protected override Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
{
_options = context.Configuration.Bind<EidasOptions>() ?? new EidasOptions();
if (!string.IsNullOrEmpty(_options.CertificatePath))
{
LoadCertificate(_options.CertificatePath, _options.CertificatePassword);
Context?.Logger.Info("eIDAS provider initialized with certificate from {Path}", _options.CertificatePath);
}
else if (!string.IsNullOrEmpty(_options.CertificateThumbprint))
{
LoadCertificateFromStore(_options.CertificateThumbprint, _options.CertificateStoreLocation);
Context?.Logger.Info("eIDAS provider initialized with certificate from store");
}
else
{
Context?.Logger.Warning("eIDAS provider initialized without certificate - signing operations will fail");
}
_timestampingConfiguration = context.Configuration.Bind<QualifiedTimestampingConfiguration>("timestamping")
?? new QualifiedTimestampingConfiguration();
_cadesBuilder = context.Services.GetService<ICadesSignatureBuilder>();
_timestampModeSelector = context.Services.GetService<ITimestampModeSelector>();
_timestampVerifier = context.Services.GetService<IQualifiedTimestampVerifier>();
return Task.CompletedTask;
}
/// <inheritdoc />
public override bool CanHandle(CryptoOperation operation, string algorithm)
{
return algorithm.StartsWith("eIDAS", StringComparison.OrdinalIgnoreCase) &&
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public override async Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
{
EnsureActive();
ct.ThrowIfCancellationRequested();
if (_signingCertificate == null || _privateKey == null)
{
throw new InvalidOperationException("No signing certificate configured.");
}
var algorithm = options.Algorithm;
byte[] signature;
if (algorithm.Contains("CAdES", StringComparison.OrdinalIgnoreCase))
{
signature = await SignCadesAsync(data, options, ct).ConfigureAwait(false);
}
else if (algorithm.Contains("ECDSA", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException("ECDSA not yet implemented for eIDAS. Use RSA algorithms.");
}
else
{
// Standard RSA signature
var hashAlgorithm = GetHashAlgorithmName(algorithm);
signature = _privateKey.SignData(data.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
}
Context?.Logger.Debug("Signed {DataLength} bytes with {Algorithm}", data.Length, algorithm);
return signature;
}
/// <inheritdoc />
public override async Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
{
EnsureActive();
ct.ThrowIfCancellationRequested();
var algorithm = options.Algorithm;
bool isValid;
if (algorithm.Contains("CAdES", StringComparison.OrdinalIgnoreCase))
{
isValid = await VerifyCadesSignatureAsync(data, signature, options, ct).ConfigureAwait(false);
}
else
{
// Load verification certificate
X509Certificate2 verificationCert;
if (!string.IsNullOrEmpty(options.CertificateChain))
{
verificationCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(options.CertificateChain));
}
else if (_signingCertificate != null)
{
verificationCert = _signingCertificate;
}
else
{
throw new InvalidOperationException("No verification certificate available.");
}
using var rsa = verificationCert.GetRSAPublicKey();
if (rsa == null)
{
throw new InvalidOperationException("Certificate does not contain RSA public key.");
}
var hashAlgorithm = GetHashAlgorithmName(algorithm);
isValid = rsa.VerifyData(data.ToArray(), signature.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
}
Context?.Logger.Debug("Verified signature: {IsValid}", isValid);
return isValid;
}
private async Task<byte[]> SignCadesAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
{
if (_signingCertificate == null)
{
throw new InvalidOperationException("Certificate not loaded for CAdES signing.");
}
var timestampContext = BuildTimestampContext(options.Metadata);
var policy = ResolveTimestampPolicy(timestampContext);
var requestedLevel = ResolveCadesLevel(options.Algorithm, policy);
var provider = FindProvider(policy.TsaProvider);
var signatureOptions = new CadesSignatureOptions
{
DigestAlgorithm = ResolveDigestAlgorithm(options.Algorithm),
TsaProvider = provider?.Name ?? policy.TsaProvider,
TsaUrl = provider?.Url ?? _options?.TimestampAuthorityUrl,
RequireQualifiedTsa = policy.IsQualified,
IncludeRevocationData = requestedLevel >= CadesLevel.CadesLT
};
if (_cadesBuilder is null)
{
Context?.Logger.Warning("CAdES builder not available; falling back to CAdES-BES.");
return CreateCadesSignature(data.ToArray());
}
return requestedLevel switch
{
CadesLevel.CadesB => await _cadesBuilder.CreateCadesBAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false),
CadesLevel.CadesT => await _cadesBuilder.CreateCadesTAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false),
CadesLevel.CadesLT => await _cadesBuilder.CreateCadesLTAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false),
CadesLevel.CadesLTA => await _cadesBuilder.CreateCadesLTAAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false),
CadesLevel.CadesC => await _cadesBuilder.CreateCadesLTAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false),
_ => await _cadesBuilder.CreateCadesBAsync(data, _signingCertificate, signatureOptions, ct).ConfigureAwait(false)
};
}
private async Task<bool> VerifyCadesSignatureAsync(
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CryptoVerifyOptions options,
CancellationToken ct)
{
if (_timestampVerifier is null)
{
return VerifyCadesSignature(data.ToArray(), signature.ToArray());
}
var requestedLevel = ResolveCadesLevel(options.Algorithm, ResolveTimestampPolicy(BuildTimestampContext(null)));
var mode = _options?.TimestampMode ?? _timestampingConfiguration?.DefaultMode ?? TimestampMode.Rfc3161;
var verifyOptions = new QualifiedTimestampVerificationOptions
{
RequireQualifiedTsa = mode == TimestampMode.Qualified || mode == TimestampMode.QualifiedLtv,
VerifyLtvCompleteness = requestedLevel >= CadesLevel.CadesLT
};
var result = await _timestampVerifier.VerifyCadesAsync(signature, data, verifyOptions, ct).ConfigureAwait(false);
return result.IsSignatureValid;
}
private TimestampPolicy ResolveTimestampPolicy(TimestampContext context)
{
if (_timestampModeSelector is not null)
{
return _timestampModeSelector.GetPolicy(context);
}
var mode = _options?.TimestampMode ?? _timestampingConfiguration?.DefaultMode ?? TimestampMode.Rfc3161;
var provider = ResolveDefaultProvider(mode);
return new TimestampPolicy
{
Mode = mode,
TsaProvider = provider,
SignatureFormat = _options?.SignatureFormat ?? CadesLevel.CadesT,
MatchedPolicy = "options"
};
}
private TimestampContext BuildTimestampContext(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null)
{
return new TimestampContext();
}
metadata.TryGetValue("environment", out var environment);
metadata.TryGetValue("repository", out var repository);
metadata.TryGetValue("tags", out var tagsValue);
metadata.TryGetValue("artifactType", out var artifactType);
metadata.TryGetValue("artifactDigest", out var artifactDigest);
return new TimestampContext
{
Environment = environment,
Repository = repository,
Tags = ParseTags(tagsValue),
ArtifactType = artifactType,
ArtifactDigest = artifactDigest,
Properties = metadata
};
}
private QualifiedTsaProvider? FindProvider(string providerName)
{
return _timestampingConfiguration?.Providers
.FirstOrDefault(p => p.Name.Equals(providerName, StringComparison.OrdinalIgnoreCase));
}
private string ResolveDefaultProvider(TimestampMode mode)
{
var isQualified = mode == TimestampMode.Qualified || mode == TimestampMode.QualifiedLtv;
var provider = _timestampingConfiguration?.Providers
.FirstOrDefault(p => p.Qualified == isQualified);
return provider?.Name ?? _timestampingConfiguration?.Providers.FirstOrDefault()?.Name ?? "default";
}
private static IReadOnlyList<string>? ParseTags(string? tagsValue)
{
if (string.IsNullOrWhiteSpace(tagsValue))
{
return null;
}
return tagsValue
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string ResolveDigestAlgorithm(string algorithm)
{
if (algorithm.Contains("512", StringComparison.OrdinalIgnoreCase))
{
return "SHA512";
}
if (algorithm.Contains("384", StringComparison.OrdinalIgnoreCase))
{
return "SHA384";
}
return "SHA256";
}
private static CadesLevel ResolveCadesLevel(string algorithm, TimestampPolicy policy)
{
var level = algorithm switch
{
var a when a.Contains("CAdES-LTA", StringComparison.OrdinalIgnoreCase) => CadesLevel.CadesLTA,
var a when a.Contains("CAdES-LT", StringComparison.OrdinalIgnoreCase) => CadesLevel.CadesLT,
var a when a.Contains("CAdES-T", StringComparison.OrdinalIgnoreCase) => CadesLevel.CadesT,
var a when a.Contains("CAdES-B", StringComparison.OrdinalIgnoreCase) => CadesLevel.CadesB,
_ => policy.SignatureFormat
};
if (policy.Mode == TimestampMode.None)
{
return CadesLevel.CadesB;
}
return level;
}
/// <inheritdoc />
public override Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
{
EnsureActive();
ct.ThrowIfCancellationRequested();
if (_signingCertificate == null)
{
throw new InvalidOperationException("No certificate configured for encryption.");
}
using var rsa = _signingCertificate.GetRSAPublicKey()
?? throw new InvalidOperationException("Certificate does not contain RSA public key.");
var encrypted = rsa.Encrypt(data.ToArray(), RSAEncryptionPadding.OaepSHA256);
Context?.Logger.Debug("Encrypted {DataLength} bytes", data.Length);
return Task.FromResult(encrypted);
}
/// <inheritdoc />
public override Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
{
EnsureActive();
ct.ThrowIfCancellationRequested();
if (_privateKey == null)
{
throw new InvalidOperationException("No private key configured for decryption.");
}
var decrypted = _privateKey.Decrypt(data.ToArray(), RSAEncryptionPadding.OaepSHA256);
Context?.Logger.Debug("Decrypted {DataLength} bytes", data.Length);
return Task.FromResult(decrypted);
}
/// <inheritdoc />
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
{
EnsureActive();
ct.ThrowIfCancellationRequested();
byte[] hash = algorithm.ToUpperInvariant() switch
{
var a when a.Contains("512") => SHA512.HashData(data.Span),
var a when a.Contains("384") => SHA384.HashData(data.Span),
_ => SHA256.HashData(data.Span)
};
Context?.Logger.Debug("Computed hash of {DataLength} bytes", data.Length);
return Task.FromResult(hash);
}
/// <inheritdoc />
public override ValueTask DisposeAsync()
{
_privateKey?.Dispose();
_signingCertificate?.Dispose();
_privateKey = null;
_signingCertificate = null;
_bcCertificate = null;
State = PluginLifecycleState.Stopped;
return ValueTask.CompletedTask;
}
private void LoadCertificate(string path, string? password)
{
_signingCertificate = X509CertificateLoader.LoadPkcs12FromFile(path, password, X509KeyStorageFlags.Exportable);
_privateKey = _signingCertificate.GetRSAPrivateKey();
_bcCertificate = DotNetUtilities.FromX509Certificate(_signingCertificate);
}
private void LoadCertificateFromStore(string thumbprint, StoreLocation storeLocation)
{
using var store = new X509Store(StoreName.My, storeLocation);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false);
if (certs.Count == 0)
{
throw new InvalidOperationException($"Certificate with thumbprint {thumbprint} not found in store.");
}
_signingCertificate = certs[0];
_privateKey = _signingCertificate.GetRSAPrivateKey();
_bcCertificate = DotNetUtilities.FromX509Certificate(_signingCertificate);
}
private byte[] CreateCadesSignature(byte[] data)
{
if (_signingCertificate == null || _privateKey == null)
{
throw new InvalidOperationException("Certificate not loaded for CAdES signing.");
}
// Simplified CAdES-BES: Use .NET CMS for signing
// For full CAdES compliance, a dedicated library like iText or DSS should be used
var contentInfo = new System.Security.Cryptography.Pkcs.ContentInfo(data);
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms(contentInfo, detached: true);
var signer = new System.Security.Cryptography.Pkcs.CmsSigner(_signingCertificate)
{
DigestAlgorithm = new Oid("2.16.840.1.101.3.4.2.1") // SHA-256
};
signedCms.ComputeSignature(signer);
return signedCms.Encode();
}
private bool VerifyCadesSignature(byte[] data, byte[] signature)
{
try
{
// Use .NET CMS verification
var contentInfo = new System.Security.Cryptography.Pkcs.ContentInfo(data);
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms(contentInfo, detached: true);
signedCms.Decode(signature);
signedCms.CheckSignature(verifySignatureOnly: true);
return true;
}
catch
{
return false;
}
}
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
var a when a.Contains("SHA512") => HashAlgorithmName.SHA512,
var a when a.Contains("SHA384") => HashAlgorithmName.SHA384,
_ => HashAlgorithmName.SHA256
};
}
}
/// <summary>
/// Configuration options for eIDAS cryptography plugin.
/// </summary>
public sealed class EidasOptions
{
/// <summary>
/// Path to PKCS#12/PFX certificate file.
/// </summary>
public string? CertificatePath { get; init; }
/// <summary>
/// Password for the certificate file.
/// </summary>
public string? CertificatePassword { get; init; }
/// <summary>
/// Certificate thumbprint for loading from Windows certificate store.
/// </summary>
public string? CertificateThumbprint { get; init; }
/// <summary>
/// Certificate store location (CurrentUser or LocalMachine).
/// </summary>
public StoreLocation CertificateStoreLocation { get; init; } = StoreLocation.CurrentUser;
/// <summary>
/// Trusted timestamp authority URL for qualified signatures.
/// </summary>
public string? TimestampAuthorityUrl { get; init; }
/// <summary>
/// Timestamp mode to use (Rfc3161, Qualified, QualifiedLtv).
/// </summary>
public TimestampMode? TimestampMode { get; init; }
/// <summary>
/// CAdES signature format level.
/// </summary>
public CadesLevel? SignatureFormat { get; init; }
/// <summary>
/// Whether to validate certificate chain during operations.
/// </summary>
public bool ValidateCertificateChain { get; init; } = true;
}