519 lines
19 KiB
C#
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;
|
|
}
|