diff --git a/README.md b/README.md index dfee1b4..1de4522 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,14 @@ The authentication token validation process consists of two stages: - First, **user certificate validation**: the validator parses the token and extracts the user certificate from the *unverifiedCertificate* field. Then it checks the certificate expiration, purpose and policies. Next it checks that the certificate is signed by a trusted CA and checks the certificate status with OCSP. - Second, **token signature validation**: the validator validates that the token signature was created using the provided user certificate by reconstructing the signed data `hash(origin)+hash(challenge)` and using the public key from the certificate to verify the signature in the `signature` field. If the signature verification succeeds, then the origin and challenge nonce have been implicitly and correctly verified without the need to implement any additional security checks. +Starting from format version `web-eid:1.1`, the authentication token may additionally carry the intermediate CA certificates needed to build the trust chains of the certificates it contains, in the `unverifiedIntermediateCertificates` field, and the eID user's signing certificates in the `unverifiedSigningCertificates` field, where each entry may carry its own `intermediateCertificates`. Like the authentication certificate, these certificates are received from the client side and cannot be trusted; the intermediate certificates are only used as candidate certificates when building the certification path, which must still terminate at a trusted certificate authority. + +When the token is in the `web-eid:1.1` format and contains `unverifiedSigningCertificates`, the validator additionally validates each signing certificate: that it has the same subject and issuer as the authentication certificate, is currently valid, is suitable for digital signatures (contains the non-repudiation key usage bit) and is signed by a trusted certificate authority, building the chain from the certificate's `intermediateCertificates` to a trusted CA. The signing certificate itself is deliberately not checked for revocation during authentication: its revocation status matters at signing time and is validated by the signature creation and validation services. + +When a token supplies intermediate CA certificates and a certification path is built through them, the validator checks the revocation status of the intermediate CA certificates in the path with the .NET platform's `X509Chain` revocation checker, which uses OCSP and CRLs, and rejects the token when the status of an intermediate is revoked or cannot be established. This check runs during path validation for both the authentication and the signing certificate chains, independently of the user certificate OCSP check configuration, because token-supplied intermediates are untrusted input. Deployments that need to accept tokens with intermediate certificates must therefore allow the network access that OCSP or CRL fetching requires. + +When the user certificate OCSP check uses an AIA OCSP responder, the responder is authorized according to [RFC 6960 section 4.2.2.2](https://datatracker.ietf.org/doc/html/rfc6960#section-4.2.2.2): the response must be signed either by the CA that issued the user certificate, in which case the OCSP-signing extended key usage is not required, or by a responder whose certificate contains the id-kp-OCSPSigning extended key usage and chains to a trusted CA through the certificate that issued the user certificate. Responder certificates are deliberately not checked for revocation, following the id-pkix-ocsp-nocheck convention of RFC 6960 section 4.2.2.2.1 and because querying an OCSP service about its own signer would be circular. + The website backend must lookup the challenge nonce from its local store using an identifier specific to the browser session, to guarantee that the authentication token was received from the same browser to which the corresponding challenge nonce was issued. The website backend must guarantee that the challenge nonce lifetime is limited and that its expiration is checked, and that it can be used only once by removing it from the store during validation. ## Basic usage @@ -474,9 +482,10 @@ The following additional configuration options are available in `AuthTokenValida - `WithoutUserCertificateRevocationCheckWithOcsp()` – turns off user certificate revocation check with OCSP. OCSP check is enabled by default and the OCSP responder access location URL is extracted from the user certificate AIA extension unless a designated OCSP service is activated. - `WithDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration serviceConfiguration)` – activates the provided designated OCSP responder service configuration for user certificate revocation check with OCSP. The designated service is only used for checking the status of the certificates whose issuers are supported by the service, for other certificates the default AIA extension service access location will be used. See configuration examples in tests. -- `WithOcspRequestTimeout(TimeSpan ocspRequestTimeout)` – sets both the connection and response timeout of user certificate revocation check OCSP requests. Default is 5 seconds. +- `WithOcspRequestTimeout(TimeSpan ocspRequestTimeout)` – sets the network timeout for user certificate OCSP requests and platform OCSP or CRL retrievals used to check intermediate certificates. Default is 5 seconds. - `WithDisallowedCertificatePolicies(params string[] policies)` – adds the given policies to the list of disallowed user certificate policies. In order for the user certificate to be considered valid, it must not contain any policies present in this list. Contains the Estonian Mobile-ID policies by default as it must not be possible to authenticate with a Mobile-ID certificate when an eID smart card is expected. - `WithNonceDisabledOcspUrls(params Uri[] urls)` – adds the given URLs to the list of OCSP URLs for which the nonce protocol extension will be disabled. Some OCSP services don't support the nonce extension. +- `WithAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy matchingPolicy)` – controls how an AIA OCSP responder's issuer is matched against the user certificate's issuer. The default `ExactCertificate` policy requires the same X.509 certificate. Use `SubjectAndPublicKey` to accept equivalent cross-certificates with the same subject and public key; this also enables revocation checking for non-anchor intermediate certificates in the responder's certification path. - `WithAllowedOcspResponseTimeSkew(TimeSpan allowedTimeSkew)` - sets the allowed time skew for OCSP response's `thisUpdate` and `nextUpdate` times to allow discrepancies between the system clock and the OCSP responder's clock or revocation updates that are not published in real time. The default allowed time skew is 15 minutes. The relatively long default is specifically chosen to account for one particular OCSP responder that used CRLs for authoritative revocation info, these CRLs were updated every 15 minutes. - `WithMaxOcspResponseThisUpdateAge(TimeSpan maxThisUpdateAge)` - sets the maximum age for the OCSP response's `thisUpdate` time before it is considered too old to rely on. The default maximum age is 2 minutes. Extended configuration example: @@ -495,6 +504,8 @@ AuthTokenValidator validator = new AuthTokenValidatorBuilder(logger) Unless a designated OCSP responder service is in use, it is required that the AIA extension that contains the certificate’s OCSP responder access location is present in the user certificate. The AIA OCSP URL will be used to check the certificate revocation status with OCSP. +By default, the certificate that directly signs an AIA OCSP response, or issues a delegated AIA OCSP responder, must exactly match the certificate that issued the user certificate. Deployments that require equivalent cross-certificates can explicitly select `ResponderIssuerMatchingPolicy.SubjectAndPublicKey` with `WithAiaOcspResponderIssuerMatchingPolicy()`. This policy also requires the revocation status of every non-anchor intermediate certificate in the responder's certification path to be established. + Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service. ## Logging diff --git a/src/WebEid.Security.Tests/Resources/ocsp_response_belgian_test_id_card.der b/src/WebEid.Security.Tests/Resources/ocsp_response_belgian_test_id_card.der new file mode 100644 index 0000000..3cf707b Binary files /dev/null and b/src/WebEid.Security.Tests/Resources/ocsp_response_belgian_test_id_card.der differ diff --git a/src/WebEid.Security.Tests/Resources/ocsp_response_finnish_test_id_card.der b/src/WebEid.Security.Tests/Resources/ocsp_response_finnish_test_id_card.der new file mode 100644 index 0000000..44dfa1b Binary files /dev/null and b/src/WebEid.Security.Tests/Resources/ocsp_response_finnish_test_id_card.der differ diff --git a/src/WebEid.Security.Tests/TestUtils/AuthTokenValidators.cs b/src/WebEid.Security.Tests/TestUtils/AuthTokenValidators.cs index c1e6872..9f015ce 100644 --- a/src/WebEid.Security.Tests/TestUtils/AuthTokenValidators.cs +++ b/src/WebEid.Security.Tests/TestUtils/AuthTokenValidators.cs @@ -24,6 +24,7 @@ namespace WebEid.Security.Tests.TestUtils using System; using System.Security.Cryptography.X509Certificates; using Security.Validator; + using Security.Validator.Ocsp; public static class AuthTokenValidators { @@ -81,6 +82,24 @@ public static IAuthTokenValidator GetAuthTokenValidatorForFinnishIdCard() => Certificates.CertificateLoader.LoadCertificatesFromResources("DVV TEST Certificates - G5E.crt", "VRK TEST CA for Test Purposes - G4.crt") ); + public static IAuthTokenValidator GetAuthTokenValidatorForBelgianIdCardWithOcspCheck(IOcspClient ocspClient) => + GetAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + Certificates.CertificateLoader.LoadCertificatesFromResources("eID TEST EC Citizen CA.cer")) + // The recorded OCSP response used in tests was created without a nonce. + .WithNonceDisabledOcspUrls(new Uri("http://eiddevcards.zetescards.be:8888")) + .WithOcspClient(ocspClient) + .Build(); + + public static IAuthTokenValidator GetAuthTokenValidatorForFinnishIdCardWithOcspCheck(IOcspClient ocspClient) => + GetAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + Certificates.CertificateLoader.LoadCertificatesFromResources("DVV TEST Certificates - G5E.crt", "VRK TEST CA for Test Purposes - G4.crt")) + // The recorded OCSP response used in tests was created without a nonce. + .WithNonceDisabledOcspUrls(new Uri("http://ocsptest.fineid.fi/dvvtp5ec")) + .WithOcspClient(ocspClient) + .Build(); + public static AuthTokenValidatorBuilder GetDefaultAuthTokenValidatorBuilder() => GetAuthTokenValidatorBuilder(TokenOriginUrl, GetCaCertificates()); diff --git a/src/WebEid.Security.Tests/TestUtils/RecordedResponseOcspClient.cs b/src/WebEid.Security.Tests/TestUtils/RecordedResponseOcspClient.cs new file mode 100644 index 0000000..d8cddc7 --- /dev/null +++ b/src/WebEid.Security.Tests/TestUtils/RecordedResponseOcspClient.cs @@ -0,0 +1,41 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Tests.TestUtils +{ + using System; + using System.Threading.Tasks; + using Org.BouncyCastle.Ocsp; + using Security.Validator.Ocsp; + + /// + /// An that always returns a pre-recorded OCSP response, mirroring the recorded-response + /// client used by the Belgian and Finnish AIA OCSP tests in the Java reference implementation. + /// + internal sealed class RecordedResponseOcspClient(byte[] responseBytes) : IOcspClient + { + public void Dispose() + { + } + + public Task Request(Uri uri, OcspReq ocspReq) => Task.FromResult(new OcspResp(responseBytes)); + } +} diff --git a/src/WebEid.Security.Tests/TestUtils/TestCertificateGenerator.cs b/src/WebEid.Security.Tests/TestUtils/TestCertificateGenerator.cs new file mode 100644 index 0000000..646637b --- /dev/null +++ b/src/WebEid.Security.Tests/TestUtils/TestCertificateGenerator.cs @@ -0,0 +1,110 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Tests.TestUtils +{ + using System; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; + using BcX509 = Org.BouncyCastle.Asn1.X509; + + /// + /// Generates ephemeral certificate hierarchies for certification-path and OCSP responder tests. + /// Mirrors the BouncyCastle-generated fixtures of the corresponding Java tests, but builds the + /// certificates with and ECDSA keys so that they can be validated by + /// the -based library implementation. + /// + internal static class TestCertificateGenerator + { + private const string OcspSigningEku = "1.3.6.1.5.5.7.3.9"; + private const string AuthorityInformationAccessOid = "1.3.6.1.5.5.7.1.1"; + + /// + /// Generates a self-signed CA certificate that can act as a trust anchor. + /// + public static X509Certificate2 GenerateSelfSignedCa(string commonName, + DateTimeOffset notBefore, DateTimeOffset notAfter) + { + var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Generates a certificate issued by the given issuer. + /// + /// The subject common name. + /// The issuing certificate; it must carry its private key. + /// Whether the generated certificate is a CA certificate. + /// Start of the validity window. + /// End of the validity window. + /// Whether to add the OCSP-signing extended key usage. + /// When set, adds an Authority Information Access OCSP responder URL. + /// When set, reuses the given key pair (used to build equivalent cross-certificates). + public static X509Certificate2 GenerateCertificate(string commonName, + X509Certificate2 issuer, + bool isCa, + DateTimeOffset notBefore, + DateTimeOffset notAfter, + bool ocspSigning = false, + string ocspUrl = null, + ECDsa key = null) + { + key ??= ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(isCa, false, 0, true)); + if (isCa) + { + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + } + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + request.CertificateExtensions.Add( + X509AuthorityKeyIdentifierExtension.CreateFromCertificate(issuer, true, false)); + if (ocspSigning) + { + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid(OcspSigningEku)], false)); + } + if (ocspUrl != null) + { + var authorityInformationAccess = new BcX509.AuthorityInformationAccess( + new BcX509.AccessDescription(BcX509.AccessDescription.IdADOcsp, + new BcX509.GeneralName(BcX509.GeneralName.UniformResourceIdentifier, ocspUrl))); + request.CertificateExtensions.Add(new X509Extension( + new Oid(AuthorityInformationAccessOid), authorityInformationAccess.GetDerEncoded(), false)); + } + + var serialNumber = RandomNumberGenerator.GetBytes(16); + serialNumber[0] &= 0x7F; // Keep the serial number positive. + // Sign with an explicit signature generator rather than the issuer-certificate Create overload: the latter + // requires the subject validity window to nest inside the issuer's, which prevents generating a currently + // valid leaf under an expired or not-yet-valid intermediate. + var generator = X509SignatureGenerator.CreateForECDsa(issuer.GetECDsaPrivateKey()); + var certificate = request.Create(issuer.SubjectName, generator, notBefore, notAfter, serialNumber); + return certificate.CopyWithPrivateKey(key); + } + } +} diff --git a/src/WebEid.Security.Tests/Util/CertificateChainValidationTests.cs b/src/WebEid.Security.Tests/Util/CertificateChainValidationTests.cs new file mode 100644 index 0000000..010461a --- /dev/null +++ b/src/WebEid.Security.Tests/Util/CertificateChainValidationTests.cs @@ -0,0 +1,263 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Tests.Util +{ + using System; + using System.Collections.Generic; + using System.Security.Cryptography.X509Certificates; + using Exceptions; + using NUnit.Framework; + using Security.Util; + using TestUtils; + + /// + /// Ports the NFC-128 additions of the Java CertificateValidatorTest: certification-path building through + /// token-supplied intermediates, direct-issuer return value, termination at the configured trust anchor, the + /// certificateSubject label in validity-failure messages and the intermediate revocation check. + /// + [TestFixture] + public sealed class CertificateChainValidationTests + { + // A single chain: root -> intermediateC -> intermediateB -> intermediateA -> leaf. + private X509Certificate2 rootCertificate; + private X509Certificate2 intermediateCertificateC; // signed by root + private X509Certificate2 intermediateCertificateB; // signed by C + private X509Certificate2 intermediateCertificateA; // signed by B, direct issuer of the leaf + private X509Certificate2 leafCertificate; // signed by A + + private DateTime now; + private DateTimeOffset notBefore; + private DateTimeOffset notAfter; + + [OneTimeSetUp] + public void SetUp() + { + var reference = DateTimeOffset.UtcNow; + now = reference.UtcDateTime; + notBefore = reference.AddDays(-1); + notAfter = reference.AddDays(1); + + rootCertificate = TestCertificateGenerator.GenerateSelfSignedCa("Test Root CA", notBefore, notAfter); + intermediateCertificateC = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA C", rootCertificate, true, notBefore, notAfter); + intermediateCertificateB = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA B", intermediateCertificateC, true, notBefore, notAfter); + intermediateCertificateA = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA A", intermediateCertificateB, true, notBefore, notAfter); + leafCertificate = TestCertificateGenerator.GenerateCertificate( + "Test Leaf", intermediateCertificateA, false, notBefore, notAfter); + } + + [Test] + public void WhenChainHasTokenSuppliedIntermediatesThenReturnsDirectIssuerNotTrustAnchor() + { + var issuer = leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "User", + [rootCertificate], + [intermediateCertificateA, intermediateCertificateB, intermediateCertificateC], + IntermediateRevocationCheck.Disabled, + now); + + // The leaf is issued by intermediate A, whose chain (A -> B -> C) leads to the root trust anchor. The + // issuer used for OCSP must be the direct issuer (intermediate A), not the trust anchor (the root). + Assert.That(issuer.Thumbprint, Is.EqualTo(intermediateCertificateA.Thumbprint)); + } + + [Test] + public void WhenSubjectIssuedDirectlyByTrustAnchorThenReturnsTrustAnchor() + { + var issuer = leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "User", + [intermediateCertificateA], + [], + IntermediateRevocationCheck.Disabled, + now); + + // Single-hop chain: the direct issuer is the trust anchor itself. + Assert.That(issuer.Thumbprint, Is.EqualTo(intermediateCertificateA.Thumbprint)); + } + + [Test] + public void WhenChainHasMultipleTokenSuppliedIntermediatesAndGrandparentIsPinnedThenValidationSucceeds() + { + // The token supplies the full A -> B -> C intermediate chain and the top (C) is configured as the trust + // anchor. The path builds leaf -> A -> B -> C, and the issuer returned for OCSP is the direct issuer (A). + var issuer = leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "User", + [intermediateCertificateC], + [intermediateCertificateA, intermediateCertificateB, intermediateCertificateC], + IntermediateRevocationCheck.Disabled, + now); + + Assert.That(issuer.Thumbprint, Is.EqualTo(intermediateCertificateA.Thumbprint)); + } + + [Test] + public void WhenTokenSuppliedIntermediateRevocationStatusIsUnknownThenRejectsCertificateChain() + { + // With the intermediate revocation check enabled, the non-anchor intermediates A and B must have a + // determinable revocation status. The ephemeral certificates carry no OCSP or CRL distribution point, so + // the hard-fail online revocation check cannot establish their status and the chain is rejected. This is + // the offline-portable counterpart of the Java test that revokes an intermediate through a CRL. + Assert.That(() => leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "User", + [rootCertificate], + [intermediateCertificateA, intermediateCertificateB, intermediateCertificateC], + IntermediateRevocationCheck.Enabled, + now), + Throws.TypeOf()); + } + + [Test] + public void WhenIntermediateRevocationPolicyIsConfiguredThenUsesFiniteRetrievalTimeout() + { + using var chain = new X509Chain(); + var timeout = TimeSpan.FromSeconds(7); + + X509CertificateExtensions.ConfigureIntermediateRevocationPolicy(chain.ChainPolicy, timeout, now); + + Assert.That(chain.ChainPolicy.UrlRetrievalTimeout, Is.EqualTo(timeout)); + Assert.That(chain.ChainPolicy.UrlRetrievalTimeout, Is.GreaterThan(TimeSpan.Zero)); + } + + [Test] + public void WhenIntermediateRevocationRetrievalTimeoutIsZeroThenRejectsPolicy() + { + using var chain = new X509Chain(); + + Assert.That(() => X509CertificateExtensions.ConfigureIntermediateRevocationPolicy( + chain.ChainPolicy, TimeSpan.Zero, now), + Throws.TypeOf()); + } + + [Test] + public void WhenCertificateExpiredThenMessageUsesProvidedSubject() + { + var afterExpiry = notAfter.AddDays(1).UtcDateTime; + + Assert.That(() => leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "Signing", + [intermediateCertificateA], + [], + IntermediateRevocationCheck.Disabled, + afterExpiry), + Throws.TypeOf() + .With.Message.EqualTo("Signing certificate has expired")); + } + + [Test] + public void WhenCertificateNotYetValidThenMessageUsesProvidedSubject() + { + var beforeValidity = notBefore.AddDays(-1).UtcDateTime; + + Assert.That(() => leafCertificate.ValidateIsValidAndSignedByTrustedCa( + "Signing", + [intermediateCertificateA], + [], + IntermediateRevocationCheck.Disabled, + beforeValidity), + Throws.TypeOf() + .With.Message.EqualTo("Signing certificate is not yet valid")); + } + + [Test] + public void WhenTokenSuppliedChainTerminatesAtUntrustedRootThenRejectsCertificateChain() + { + // The token supplies a complete, internally consistent chain whose self-signed root is not a configured + // trust anchor. Token-supplied certificates are certification-path candidates only, never trust anchors, + // so the chain must be rejected even though every signature in it verifies. + var rogueRoot = TestCertificateGenerator.GenerateSelfSignedCa("Rogue Root CA", notBefore, notAfter); + var rogueIntermediate = TestCertificateGenerator.GenerateCertificate( + "Rogue Intermediate CA", rogueRoot, true, notBefore, notAfter); + var rogueLeaf = TestCertificateGenerator.GenerateCertificate( + "Rogue Leaf", rogueIntermediate, false, notBefore, notAfter); + + Assert.That(() => rogueLeaf.ValidateIsValidAndSignedByTrustedCa( + "User", + [rootCertificate], + [rogueIntermediate, rogueRoot], + IntermediateRevocationCheck.Enabled, + now), + Throws.TypeOf() + .With.Message.EqualTo("Certificate CN=Rogue Leaf is not trusted")); + } + + [Test] + public void WhenTokenSuppliedIntermediateIsExpiredThenRejectsCertificateChain() + { + // Only the token-supplied intermediate is outside its validity window; the leaf itself is currently valid. + var localRoot = TestCertificateGenerator.GenerateSelfSignedCa("Local Root CA", notBefore, notAfter); + var expiredIntermediate = TestCertificateGenerator.GenerateCertificate( + "Expired Intermediate CA", localRoot, true, notBefore.AddDays(-2), notBefore.AddDays(-1)); + var currentLeaf = TestCertificateGenerator.GenerateCertificate( + "Current Leaf", expiredIntermediate, false, notBefore, notAfter); + + Assert.That(() => currentLeaf.ValidateIsValidAndSignedByTrustedCa( + "User", + [localRoot], + [expiredIntermediate], + IntermediateRevocationCheck.Disabled, + now), + Throws.TypeOf()); + } + + [Test] + public void WhenTokenSuppliedIntermediateIsNotYetValidThenRejectsCertificateChain() + { + // Only the token-supplied intermediate is outside its validity window; the leaf itself is currently valid. + var localRoot = TestCertificateGenerator.GenerateSelfSignedCa("Local Root CA", notBefore, notAfter); + var notYetValidIntermediate = TestCertificateGenerator.GenerateCertificate( + "Not Yet Valid Intermediate CA", localRoot, true, notAfter.AddDays(1), notAfter.AddDays(2)); + var currentLeaf = TestCertificateGenerator.GenerateCertificate( + "Current Leaf", notYetValidIntermediate, false, notBefore, notAfter); + + Assert.That(() => currentLeaf.ValidateIsValidAndSignedByTrustedCa( + "User", + [localRoot], + [notYetValidIntermediate], + IntermediateRevocationCheck.Disabled, + now), + Throws.TypeOf()); + } + + [Test] + public void WhenTrustAnchorIsExpiredThenRejectsCertificateChain() + { + // The Java test expects a CertificateExpiredException from an explicit anchor validity check. On the + // .NET/OpenSSL X509Chain the expired anchor is part of the built path and makes the path build itself + // fail, so the library reports it as a not-trusted certificate. Either way the expired anchor is rejected + // while the leaf is currently valid. + var expiredRoot = TestCertificateGenerator.GenerateSelfSignedCa( + "Expired Root CA", notBefore.AddDays(-2), notBefore.AddDays(-1)); + var currentLeaf = TestCertificateGenerator.GenerateCertificate( + "Current Leaf", expiredRoot, false, notBefore, notAfter); + + Assert.That(() => currentLeaf.ValidateIsValidAndSignedByTrustedCa( + "User", + [expiredRoot], + [], + IntermediateRevocationCheck.Disabled, + now), + Throws.TypeOf()); + } + } +} diff --git a/src/WebEid.Security.Tests/Util/X509CertificateExtensionsTests.cs b/src/WebEid.Security.Tests/Util/X509CertificateExtensionsTests.cs index 5c1c82b..8d7f450 100644 --- a/src/WebEid.Security.Tests/Util/X509CertificateExtensionsTests.cs +++ b/src/WebEid.Security.Tests/Util/X509CertificateExtensionsTests.cs @@ -58,6 +58,35 @@ public void GetSubjectSurnameReturnsCorrectValue() => public void GetSubjectCountryCodeReturnsCorrectValue() => Assert.That("EE", Is.EqualTo(certificate.GetSubjectCountryCode())); + [Test] + public void ParseCertificatesWithNullListReturnsEmptyList() => + Assert.That(X509CertificateExtensions.ParseCertificates(null, "unverifiedIntermediateCertificates"), + Is.Empty); + + [Test] + public void ParseCertificatesWithEmptyListReturnsEmptyList() => + Assert.That(X509CertificateExtensions.ParseCertificates([], "unverifiedIntermediateCertificates"), + Is.Empty); + + [Test] + public void ParseCertificatesDecodesAllCertificates() + { + var certificateInBase64 = Convert.ToBase64String(certificate.GetRawCertData()); + + var result = X509CertificateExtensions.ParseCertificates( + [certificateInBase64, certificateInBase64], "unverifiedIntermediateCertificates"); + + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].RawData, Is.EqualTo(certificate.GetRawCertData())); + Assert.That(result[1].RawData, Is.EqualTo(certificate.GetRawCertData())); + } + + [Test] + public void ParseCertificatesWithInvalidEntryThrows() => + Assert.Throws(() => + X509CertificateExtensions.ParseCertificates(["not a certificate"], "unverifiedIntermediateCertificates")) + .WithMessage("'unverifiedIntermediateCertificates' field must contain a valid certificate"); + [Test] public void ValidateBcNotYetValidCertificateExpiryThrowsException() => Assert.Throws(() => diff --git a/src/WebEid.Security.Tests/Validator/AuthTokenCertificateBelgianIdCardTest.cs b/src/WebEid.Security.Tests/Validator/AuthTokenCertificateBelgianIdCardTest.cs index d29af2b..2ec64fa 100644 --- a/src/WebEid.Security.Tests/Validator/AuthTokenCertificateBelgianIdCardTest.cs +++ b/src/WebEid.Security.Tests/Validator/AuthTokenCertificateBelgianIdCardTest.cs @@ -68,5 +68,21 @@ public void WhenIdCardWithRSASignatureCertificateIsValidatedThenValidationSuccee Assert.DoesNotThrowAsync(() => validator.Validate(token, "YPVgYc7Qds0qmK/RilPLffnsIg7IIovM4BAWqGZWwiY=")); } + [Test] + public void WhenIdCardIsValidatedWithAiaOcspCheckThenDelegatedResponderIsAuthorizedAndValidationSucceeds() + { + // The OCSP response was recorded from the card's AIA OCSP responder at http://eiddevcards.zetescards.be:8888 + // on 2026-07-02. Its responder certificate is issued by eID TEST EC Citizen CA, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + using var _ = DateTimeProvider.OverrideUtcNow(new DateTime(2026, 7, 2, 8, 39, 30, DateTimeKind.Utc)); + var ocspClient = new RecordedResponseOcspClient( + Certificates.ResourceReader.ReadFromResource("ocsp_response_belgian_test_id_card.der")); + var validator = AuthTokenValidators.GetAuthTokenValidatorForBelgianIdCardWithOcspCheck(ocspClient); + var token = validator.Parse(BelgianTestIdCardAuthTokenEcc); + + Assert.DoesNotThrowAsync(() => validator.Validate(token, "iMeEwP2cgUINY2XoO/lqEpOUn7z/ysHRqGXkGKC4VXE=")); + } + } } diff --git a/src/WebEid.Security.Tests/Validator/AuthTokenCertificateFinnishIdCardTest.cs b/src/WebEid.Security.Tests/Validator/AuthTokenCertificateFinnishIdCardTest.cs index 611f250..a0508a0 100644 --- a/src/WebEid.Security.Tests/Validator/AuthTokenCertificateFinnishIdCardTest.cs +++ b/src/WebEid.Security.Tests/Validator/AuthTokenCertificateFinnishIdCardTest.cs @@ -68,5 +68,21 @@ public void WhenIdCardSignatureCertificateWithG4RootCertificateIsValidatedThenVa Assert.DoesNotThrowAsync(() => validator.Validate(token, "ZqlDATkQRqh7LkqEbspBc2qDjot29oiNLlITdLgiVIo=")); } + [Test] + public void WhenIdCardIsValidatedWithAiaOcspCheckThenDelegatedResponderIsAuthorizedAndValidationSucceeds() + { + // The OCSP response was recorded from the card's AIA OCSP responder at http://ocsptest.fineid.fi/dvvtp5ec + // on 2026-07-02. Its responder certificate is issued by DVV TEST Certificates - G5E, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + using var _ = DateTimeProvider.OverrideUtcNow(new DateTime(2026, 7, 2, 8, 39, 30, DateTimeKind.Utc)); + var ocspClient = new RecordedResponseOcspClient( + Certificates.ResourceReader.ReadFromResource("ocsp_response_finnish_test_id_card.der")); + var validator = AuthTokenValidators.GetAuthTokenValidatorForFinnishIdCardWithOcspCheck(ocspClient); + var token = validator.Parse(FinnishTestIdCardBackmanJuhaniAuthToken); + + Assert.DoesNotThrowAsync(() => validator.Validate(token, "x9qZDRO/ao2zprt3Z0bkW4CvvE/gALFtUIf3tcC0XxY=")); + } + } } diff --git a/src/WebEid.Security.Tests/Validator/AuthTokenStructureTest.cs b/src/WebEid.Security.Tests/Validator/AuthTokenStructureTest.cs index 26da385..702e26c 100644 --- a/src/WebEid.Security.Tests/Validator/AuthTokenStructureTest.cs +++ b/src/WebEid.Security.Tests/Validator/AuthTokenStructureTest.cs @@ -44,9 +44,18 @@ public void WhenTokenTooShortThenParsingFails() => [Test] public void WhenTokenTooLongThenParsingFails() => - Assert.Throws(() => Validator.Parse(new string(new char[10001]))) + Assert.Throws(() => Validator.Parse(new string(new char[65537]))) .WithMessage("Auth token is too long"); + [TestCase(10001)] + [TestCase(65536)] + public void WhenTokenIsWithinIncreasedLengthLimitThenParsingSucceeds(int tokenLength) + { + var token = ValidAuthTokenStr + new string(' ', tokenLength - ValidAuthTokenStr.Length); + + Assert.That(Validator.Parse(token).Format, Is.EqualTo("web-eid:1")); + } + [Test] public void WhenUnknownTokenVersionThenParsingFailsAsync() { diff --git a/src/WebEid.Security.Tests/Validator/AuthTokenValidatorBuilderTest.cs b/src/WebEid.Security.Tests/Validator/AuthTokenValidatorBuilderTest.cs index 30ed539..c96f331 100644 --- a/src/WebEid.Security.Tests/Validator/AuthTokenValidatorBuilderTest.cs +++ b/src/WebEid.Security.Tests/Validator/AuthTokenValidatorBuilderTest.cs @@ -78,6 +78,44 @@ public void WhenOriginExcessiveElementsThenBuildingFails() .WithMessage("Origin URI must only contain the HTTPS scheme, host and optional port component"); } + [Test] + public void AiaOcspResponderIssuerMatchingPolicyDefaultsToExactCertificate() + { + var configuration = new AuthTokenValidationConfiguration(); + + Assert.That(configuration.AiaOcspResponderIssuerMatchingPolicy, + Is.EqualTo(ResponderIssuerMatchingPolicy.ExactCertificate)); + Assert.That(configuration.Copy().AiaOcspResponderIssuerMatchingPolicy, + Is.EqualTo(ResponderIssuerMatchingPolicy.ExactCertificate)); + } + + [Test] + public void AiaOcspResponderIssuerMatchingPolicyIsCopied() + { + var configuration = new AuthTokenValidationConfiguration + { + AiaOcspResponderIssuerMatchingPolicy = ResponderIssuerMatchingPolicy.SubjectAndPublicKey + }; + + Assert.That(configuration.Copy().AiaOcspResponderIssuerMatchingPolicy, + Is.EqualTo(ResponderIssuerMatchingPolicy.SubjectAndPublicKey)); + } + + [Test] + public void WithAiaOcspResponderIssuerMatchingPolicySetsPolicy() + { + var returnedBuilder = builderWithLogger + .WithAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy.SubjectAndPublicKey); + + Assert.That(returnedBuilder, Is.SameAs(builderWithLogger)); + + var configurationField = typeof(AuthTokenValidatorBuilder) + .GetField("configuration", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var configuration = (AuthTokenValidationConfiguration)configurationField!.GetValue(builderWithLogger); + Assert.That(configuration!.AiaOcspResponderIssuerMatchingPolicy, + Is.EqualTo(ResponderIssuerMatchingPolicy.SubjectAndPublicKey)); + } + [Test] public void WhenOriginProtocolHttpThenBuildingFails() { diff --git a/src/WebEid.Security.Tests/Validator/Ocsp/OcspServiceProviderTests.cs b/src/WebEid.Security.Tests/Validator/Ocsp/OcspServiceProviderTests.cs index 67ea18f..cccb172 100644 --- a/src/WebEid.Security.Tests/Validator/Ocsp/OcspServiceProviderTests.cs +++ b/src/WebEid.Security.Tests/Validator/Ocsp/OcspServiceProviderTests.cs @@ -22,6 +22,7 @@ namespace WebEid.Security.Tests.Validator.Ocsp { using System; + using System.Security.Cryptography.X509Certificates; using Exceptions; using NUnit.Framework; using Org.BouncyCastle.Security; @@ -36,7 +37,10 @@ public class OcspServiceProviderTests public void WhenDesignatedOcspServiceConfigurationProvidedThenCreatesDesignatedOcspService() { var ocspServiceProvider = OcspServiceMaker.GetDesignatedOcspServiceProvider(); - var service = ocspServiceProvider.GetService(DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert())); + var service = ocspServiceProvider.GetService( + DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert()), + new X509Certificate2(Certificates.GetTestEsteid2018Ca()), + []); Assert.That(service.AccessLocation, Is.EqualTo(new Uri("http://demo.sk.ee/ocsp"))); Assert.That(service.DoesSupportNonce, Is.True); Assert.DoesNotThrow(() => @@ -55,7 +59,10 @@ public void WhenDesignatedOcspServiceConfigurationProvidedThenCreatesDesignatedO public void WhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspService() { var ocspServiceProvider = OcspServiceMaker.GetAiaOcspServiceProvider(); - var service2018 = ocspServiceProvider.GetService(DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert())); + var service2018 = ocspServiceProvider.GetService( + DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert()), + new X509Certificate2(Certificates.GetTestEsteid2018Ca()), + []); Assert.That(service2018.AccessLocation, Is.EqualTo(new Uri("http://aia.demo.sk.ee/esteid2018"))); Assert.That(service2018.DoesSupportNonce, Is.True); Assert.DoesNotThrow(() => @@ -64,7 +71,10 @@ public void WhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspService() DotNetUtilities.FromX509Certificate(Certificates.GetTestEsteid2018Ca()), CheckMoment)); - var service2015 = ocspServiceProvider.GetService(DotNetUtilities.FromX509Certificate(Certificates.GetMariliisEsteid2015Cert())); + var service2015 = ocspServiceProvider.GetService( + DotNetUtilities.FromX509Certificate(Certificates.GetMariliisEsteid2015Cert()), + new X509Certificate2(Certificates.GetTestEsteid2015Ca()), + []); Assert.That(service2015.AccessLocation, Is.EqualTo(new Uri("http://aia.demo.sk.ee/esteid2015"))); Assert.That(service2015.DoesSupportNonce, Is.False); Assert.DoesNotThrow(() => @@ -78,7 +88,10 @@ public void WhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspService() public void WhenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCaThenThrows() { var ocspServiceProvider = OcspServiceMaker.GetAiaOcspServiceProvider(); - var service2018 = ocspServiceProvider.GetService(DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert())); + var service2018 = ocspServiceProvider.GetService( + DotNetUtilities.FromX509Certificate(Certificates.GetJaakKristjanEsteid2018Cert()), + new X509Certificate2(Certificates.GetTestEsteid2018Ca()), + []); var wrongResponderCert = DotNetUtilities.FromX509Certificate(Certificates.GetMariliisEsteid2015Cert()); Assert.Throws(() => service2018.ValidateResponderCertificate(wrongResponderCert, CheckMoment)); diff --git a/src/WebEid.Security.Tests/Validator/Ocsp/Service/AiaOcspServiceTests.cs b/src/WebEid.Security.Tests/Validator/Ocsp/Service/AiaOcspServiceTests.cs new file mode 100644 index 0000000..bc6e856 --- /dev/null +++ b/src/WebEid.Security.Tests/Validator/Ocsp/Service/AiaOcspServiceTests.cs @@ -0,0 +1,266 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Tests.Validator.Ocsp.Service +{ + using System; + using System.Security.Cryptography.X509Certificates; + using Exceptions; + using NUnit.Framework; + using Org.BouncyCastle.Security; + using Security.Validator.Ocsp; + using Security.Validator.Ocsp.Service; + using TestUtils; + using BcX509Certificate = Org.BouncyCastle.X509.X509Certificate; + + /// + /// Ports the NFC-128 AiaOcspServiceTest: AIA responder path building through token-supplied intermediates and the + /// authorization boundary between CA-delegated AIA responders and explicitly configured designated responders. + /// + /// + /// The .NET has no CRL/CertStore parameter, so it always behaves like the + /// Java "empty store" case: under the SubjectAndPublicKey policy the non-anchor intermediate revocation check is + /// enabled and hard-fails offline for the ephemeral certificates (which carry no OCSP/CRL distribution point). The + /// Java scenarios that need offline revocation data to succeed under SubjectAndPublicKey are therefore covered by + /// the unknown-status test rather than by a success assertion. + /// + [TestFixture] + public sealed class AiaOcspServiceTests + { + private const string OcspUrl = "http://ocsp.example/responder"; + + private X509Certificate2 rootCertificate; + private X509Certificate2 intermediateCertificate; + private X509Certificate2 crossIntermediateCertificate; + private X509Certificate2 impostorIntermediateCertificate; + private X509Certificate2 impostorResponderCertificate; + private X509Certificate2 responderCertificate; + private X509Certificate2 noEkuResponderCertificate; + private X509Certificate2 rootIssuedResponderCertificate; + private X509Certificate2 siblingIntermediateCertificate; + private X509Certificate2 siblingResponderCertificate; + private BcX509Certificate subjectCertificate; + + private DateTime now; + + [OneTimeSetUp] + public void SetUp() + { + var reference = DateTimeOffset.UtcNow; + now = reference.UtcDateTime; + var notBefore = reference.AddDays(-1); + var notAfter = reference.AddDays(1); + + rootCertificate = TestCertificateGenerator.GenerateSelfSignedCa("Test Root CA", notBefore, notAfter); + intermediateCertificate = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA", rootCertificate, true, notBefore, notAfter); + // An equivalent cross-certificate for the intermediate CA: same subject and public key as + // intermediateCertificate, but a distinct certificate (different serial). RFC 6960 authorization must accept + // it under the SubjectAndPublicKey policy. + crossIntermediateCertificate = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA", rootCertificate, true, notBefore, notAfter, + key: intermediateCertificate.GetECDsaPrivateKey()); + // An impostor CA with the same subject name as the intermediate CA but a different key pair. A responder + // delegated by it must not be treated as authorized by the subject certificate's issuer. + impostorIntermediateCertificate = TestCertificateGenerator.GenerateCertificate( + "Test Intermediate CA", rootCertificate, true, notBefore, notAfter); + impostorResponderCertificate = TestCertificateGenerator.GenerateCertificate( + "Impostor OCSP Responder", impostorIntermediateCertificate, false, notBefore, notAfter, ocspSigning: true); + // The OCSP responder is delegated by the intermediate CA (RFC 6960 CA-designated responder). + responderCertificate = TestCertificateGenerator.GenerateCertificate( + "Test OCSP Responder", intermediateCertificate, false, notBefore, notAfter, ocspSigning: true); + // A responder issued by the intermediate CA but without the OCSP-signing extended key usage; a delegated + // responder must carry it. + noEkuResponderCertificate = TestCertificateGenerator.GenerateCertificate( + "No EKU OCSP Responder", intermediateCertificate, false, notBefore, notAfter); + // This responder is trusted through the same root, but it is not delegated by the subject certificate's + // issuer. It can only be used as a locally configured trusted responder (RFC 6960 section 4.2.2.2). + rootIssuedResponderCertificate = TestCertificateGenerator.GenerateCertificate( + "Root-Issued OCSP Responder", rootCertificate, false, notBefore, notAfter, ocspSigning: true); + siblingIntermediateCertificate = TestCertificateGenerator.GenerateCertificate( + "Sibling Intermediate CA", rootCertificate, true, notBefore, notAfter); + siblingResponderCertificate = TestCertificateGenerator.GenerateCertificate( + "Sibling OCSP Responder", siblingIntermediateCertificate, false, notBefore, notAfter, ocspSigning: true); + // The subject certificate serves two purposes: AiaOcspService reads the AIA OCSP URL from it, and the + // designated-responder test needs its issuer name to match intermediateCertificate's subject. + var subject = TestCertificateGenerator.GenerateCertificate( + "Test Subject", intermediateCertificate, false, notBefore, notAfter, ocspUrl: OcspUrl); + subjectCertificate = DotNetUtilities.FromX509Certificate(subject); + } + + [Test] + public void WhenMatchingPolicyIsNotSpecifiedThenExactCertificateMatchingIsUsed() + { + var configuration = new AiaOcspServiceConfiguration([], [rootCertificate]); + Assert.That(configuration.ResponderIssuerMatchingPolicy, + Is.EqualTo(ResponderIssuerMatchingPolicy.ExactCertificate)); + } + + [Test] + public void WhenResponderChainsViaTokenIntermediateThenValidationSucceeds() + { + var service = AiaServiceFor(intermediateCertificate, [intermediateCertificate]); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(responderCertificate), now), Throws.Nothing); + } + + [Test] + public void WhenIntermediateRevocationStatusIsUnknownThenOnlySubjectAndPublicKeyPolicyFails() + { + var exactService = AiaServiceFor(intermediateCertificate, [intermediateCertificate], + ResponderIssuerMatchingPolicy.ExactCertificate); + var subjectAndPublicKeyService = AiaServiceFor(intermediateCertificate, [intermediateCertificate], + ResponderIssuerMatchingPolicy.SubjectAndPublicKey); + + Assert.That(() => exactService.ValidateResponderCertificate(ToBc(responderCertificate), now), Throws.Nothing); + Assert.That(() => subjectAndPublicKeyService.ValidateResponderCertificate(ToBc(responderCertificate), now), + Throws.TypeOf()); + } + + [Test] + public void WhenResponderChainsViaTokenIntermediateButIntermediateMissingThenValidationFails() + { + var service = AiaServiceFor(intermediateCertificate, []); + + // Without the token-supplied intermediate, the responder -> intermediate -> root path cannot be built. + Assert.That(() => service.ValidateResponderCertificate(ToBc(responderCertificate), now), + Throws.TypeOf()); + } + + [Test] + public void WhenResponderIssuerIsEquivalentCrossCertificateWithDefaultPolicyThenValidationFails() + { + // The responder still chains to the root via the real intermediate, so its issuer in the built path is + // intermediateCertificate. The subject issuer is passed as the equivalent cross-certificate (same subject + // and public key, different certificate), which the default exact-certificate policy must reject. + var service = AiaServiceFor(crossIntermediateCertificate, [intermediateCertificate]); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(responderCertificate), now), + Throws.TypeOf() + .With.Message.Contains("OCSP responder is not authorized by the subject certificate issuer")); + } + + [Test] + public void WhenResponseIsSignedByEquivalentCrossCertificateWithDefaultPolicyThenValidationFails() + { + var service = AiaServiceFor(intermediateCertificate, []); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(crossIntermediateCertificate), now), + Throws.TypeOf() + .With.Message.Contains("equivalent to but not the same as the subject certificate issuer")); + } + + [Test] + public void WhenResponseIsSignedByEquivalentCrossCertificateWithSubjectAndPublicKeyPolicyThenValidationSucceeds() + { + // The cross-certificate is issued directly by the trust anchor (root), so the responder path has no + // non-anchor intermediate to revocation-check; the SubjectAndPublicKey policy then accepts it. + var service = AiaServiceFor(intermediateCertificate, [], + ResponderIssuerMatchingPolicy.SubjectAndPublicKey); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(crossIntermediateCertificate), now), + Throws.Nothing); + } + + [Test] + public void WhenResponderIsIssuedBySiblingIntermediateThenValidationFails() + { + var service = AiaServiceFor(intermediateCertificate, + [intermediateCertificate, siblingIntermediateCertificate]); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(siblingResponderCertificate), now), + Throws.TypeOf()); + } + + [TestCase(ResponderIssuerMatchingPolicy.ExactCertificate)] + [TestCase(ResponderIssuerMatchingPolicy.SubjectAndPublicKey)] + public void WhenResponderIssuerHasSameNameButDifferentKeyThanSubjectIssuerThenValidationFails( + ResponderIssuerMatchingPolicy matchingPolicy) + { + // Under ExactCertificate the responder is rejected because its issuer is not authorized by the subject + // certificate issuer; under SubjectAndPublicKey the impostor intermediate additionally fails the offline + // intermediate revocation check. Either way the impostor responder is rejected. + var service = AiaServiceFor(intermediateCertificate, [impostorIntermediateCertificate], matchingPolicy); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(impostorResponderCertificate), now), + Throws.TypeOf()); + } + + [Test] + public void WhenResponderIsIssuedByRootInsteadOfSubjectIssuerThenAiaValidationFails() + { + var service = AiaServiceFor(intermediateCertificate, []); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(rootIssuedResponderCertificate), now), + Throws.TypeOf()); + } + + [TestCase(ResponderIssuerMatchingPolicy.ExactCertificate)] + [TestCase(ResponderIssuerMatchingPolicy.SubjectAndPublicKey)] + public void WhenResponseSignedByIssuingCaWithoutOcspSigningEkuThenValidationSucceeds( + ResponderIssuerMatchingPolicy matchingPolicy) + { + // RFC 6960 section 4.2.2.2: a response signed by the CA that issued the subject certificate is authorized + // by CA identity alone; the OCSP-signing extended key usage is required only for delegated responders. The + // intermediate CA certificate does not carry the extended key usage. + var service = AiaServiceFor(intermediateCertificate, [intermediateCertificate], matchingPolicy); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(intermediateCertificate), now), Throws.Nothing); + } + + [Test] + public void WhenDelegatedResponderLacksOcspSigningEkuThenValidationFails() + { + var service = AiaServiceFor(intermediateCertificate, [intermediateCertificate]); + + Assert.That(() => service.ValidateResponderCertificate(ToBc(noEkuResponderCertificate), now), + Throws.TypeOf() + .With.Message.Contains( + "does not contain the extended key usage extension value for OCSP response signing")); + } + + [Test] + public void WhenRootIssuedResponderIsExplicitlyDesignatedForSubjectIssuerThenValidationSucceeds() + { + var designatedConfiguration = new DesignatedOcspServiceConfiguration( + new Uri(OcspUrl), ToBc(rootIssuedResponderCertificate), [ToBc(intermediateCertificate)], true); + var provider = new OcspServiceProvider(designatedConfiguration, + new AiaOcspServiceConfiguration([], [rootCertificate])); + var service = provider.GetService(subjectCertificate, intermediateCertificate, []); + + Assert.That(service, Is.InstanceOf()); + Assert.That(() => service.ValidateResponderCertificate(ToBc(rootIssuedResponderCertificate), now), + Throws.Nothing); + } + + private AiaOcspService AiaServiceFor(X509Certificate2 certificateIssuerCertificate, + X509Certificate2[] additionalIntermediateCertificates, + ResponderIssuerMatchingPolicy matchingPolicy = ResponderIssuerMatchingPolicy.ExactCertificate) + { + var configuration = new AiaOcspServiceConfiguration([], [rootCertificate], matchingPolicy); + return new AiaOcspService(configuration, subjectCertificate, + certificateIssuerCertificate, additionalIntermediateCertificates); + } + + private static BcX509Certificate ToBc(X509Certificate2 certificate) => + DotNetUtilities.FromX509Certificate(certificate); + } +} diff --git a/src/WebEid.Security.Tests/Validator/Validators/SubjectCertificateNotRevokedValidatorTests.cs b/src/WebEid.Security.Tests/Validator/Validators/SubjectCertificateNotRevokedValidatorTests.cs index b9e50ef..11c31e4 100644 --- a/src/WebEid.Security.Tests/Validator/Validators/SubjectCertificateNotRevokedValidatorTests.cs +++ b/src/WebEid.Security.Tests/Validator/Validators/SubjectCertificateNotRevokedValidatorTests.cs @@ -217,7 +217,8 @@ public void WhenOcspResponseUnknownThenThrows() ocspClientMock, ocspServiceProvider, configuration.AllowedOcspResponseTimeSkew, - configuration.MaxOcspResponseThisUpdateAge); + configuration.MaxOcspResponseThisUpdateAge, + []); Assert.ThrowsAsync(() => validator.Validate(esteid2018Cert)) .InnerException.IsInstanceOf() @@ -297,7 +298,7 @@ private static byte[] GetOcspResponseBytesFromResource() => Certificates.ResourceReader.ReadFromResource("ocsp_response.der"); private SubjectCertificateNotRevokedValidator GetSubjectCertificateNotRevokedValidatorWithAiaOcsp(IOcspClient client) => - new(trustedValidator, client, OcspServiceMaker.GetAiaOcspServiceProvider(), configuration.AllowedOcspResponseTimeSkew, configuration.MaxOcspResponseThisUpdateAge); + new(trustedValidator, client, OcspServiceMaker.GetAiaOcspServiceProvider(), configuration.AllowedOcspResponseTimeSkew, configuration.MaxOcspResponseThisUpdateAge, []); private static void SetSubjectCertificateIssuerCertificate(SubjectCertificateTrustedValidator trustedValidator) => SetPrivatePropertyValue(trustedValidator, "SubjectCertificateIssuerCertificate", new X509Certificate2(Certificates.GetTestEsteid2018Ca())); @@ -320,7 +321,7 @@ private static void SetPrivatePropertyValue(object obj, string propName, T va } private SubjectCertificateNotRevokedValidator GetSubjectCertificateNotRevokedValidatior(OcspServiceProvider ocspServiceProvider) - => new(trustedValidator, ocspClient, ocspServiceProvider, configuration.AllowedOcspResponseTimeSkew, configuration.MaxOcspResponseThisUpdateAge); + => new(trustedValidator, ocspClient, ocspServiceProvider, configuration.AllowedOcspResponseTimeSkew, configuration.MaxOcspResponseThisUpdateAge, []); private sealed class OcspClientMock : IOcspClient { diff --git a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenV11CertificateTest.cs b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenV11CertificateTest.cs index bd7a82a..6f830d2 100644 --- a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenV11CertificateTest.cs +++ b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenV11CertificateTest.cs @@ -28,6 +28,7 @@ namespace WebEid.Security.Tests.Validator.VersionValidators using Exceptions; using NUnit.Framework; using TestUtils; + using WebEid.Security.Util; using WebEid.Security.Validator; public class AuthTokenV11CertificateTest : AbstractTestWithValidator @@ -140,6 +141,75 @@ public void WhenV11SigningCertificateKeyUsageInvalidThenValidationFails() Validator.Validate(token, ValidChallengeNonce)); } + [Test] + public void WhenValidV11TokenWithUnverifiedIntermediateCertificatesThenValidationSucceeds() + { + using var _ = DateTimeProvider.OverrideUtcNow(new DateTime(2023, 10, 1)); + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedIntermediateCertificates = [Esteid2018CaCertificateInBase64()]; + + Assert.DoesNotThrowAsync(() => Validator.Validate(token, ValidChallengeNonce)); + } + + [Test] + public void WhenUnverifiedIntermediateCertificatesEmptyThenValidationFails() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedIntermediateCertificates = []; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, + Is.EqualTo("'unverifiedIntermediateCertificates' must not be empty for format 'web-eid:1.1'")); + } + + [Test] + public void WhenUnverifiedIntermediateCertificateContainsEmptyEntryThenValidationFails() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedIntermediateCertificates = [""]; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, Is.EqualTo( + "'unverifiedIntermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'")); + } + + [Test] + public void WhenUnverifiedIntermediateCertificateIsNotBase64ThenValidationFails() + { + // The Java test asserts a CertificateDecodingException; the .NET certificate parser reports the decoding + // failure as an AuthTokenParseException naming the offending field. + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedIntermediateCertificates = ["This is not a certificate"]; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, + Is.EqualTo("'unverifiedIntermediateCertificates' field must contain a valid certificate")); + } + + [Test] + public void WhenUnverifiedSigningCertificatesAbsentButUnverifiedIntermediateCertificatesPresentThenValidationSucceeds() + { + using var _ = DateTimeProvider.OverrideUtcNow(new DateTime(2023, 10, 1)); + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates = null; + token.UnverifiedIntermediateCertificates = [Esteid2018CaCertificateInBase64()]; + + Assert.DoesNotThrowAsync(() => Validator.Validate(token, ValidChallengeNonce)); + } + + [Test] + public void WhenValidV11TokenWithSigningIntermediateCertificatesThenValidationSucceeds() + { + using var _ = DateTimeProvider.OverrideUtcNow(new DateTime(2023, 10, 1)); + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates[0].IntermediateCertificates = [Esteid2018CaCertificateInBase64()]; + + Assert.DoesNotThrowAsync(() => Validator.Validate(token, ValidChallengeNonce)); + } + + private static string Esteid2018CaCertificateInBase64() => + Convert.ToBase64String(Certificates.GetTestEsteid2018Ca().GetRawCertData()); + private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new() { PropertyNameCaseInsensitive = true diff --git a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion11ValidatorTest.cs b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion11ValidatorTest.cs index b523376..ba7a35d 100644 --- a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion11ValidatorTest.cs +++ b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion11ValidatorTest.cs @@ -129,5 +129,53 @@ public void WhenSigningCertificateChainValidationSucceedsThenValidationSucceeds( Assert.DoesNotThrowAsync(() => Validator.Validate(token, ValidChallengeNonce)); } + + [Test] + public void WhenUnverifiedSigningCertificatesMissingButIntermediateCertificatesPresentThenValidationSucceeds() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates = null; + token.UnverifiedIntermediateCertificates = [GetTestEsteid2018CaInBase64()]; + + Assert.DoesNotThrowAsync(() => + Validator.Validate(token, ValidChallengeNonce)); + } + + [Test] + public void WhenUnverifiedSigningCertificatesEmptyThenValidationFails() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates = []; + token.UnverifiedIntermediateCertificates = [GetTestEsteid2018CaInBase64()]; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, Is.EqualTo( + "'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'")); + } + + [Test] + public void WhenSigningCertificateIntermediateCertificatesEmptyThenValidationFails() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates[0].IntermediateCertificates = []; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, Is.EqualTo( + "'intermediateCertificates' must not be empty for format 'web-eid:1.1'")); + } + + [Test] + public void WhenSigningCertificateIntermediateCertificatesContainsEmptyEntryThenValidationFails() + { + var token = Validator.Parse(ValidV11AuthTokenStr); + token.UnverifiedSigningCertificates[0].IntermediateCertificates = [""]; + + var ex = Assert.ThrowsAsync(() => Validator.Validate(token, ValidChallengeNonce)); + Assert.That(ex!.Message, Is.EqualTo( + "'intermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'")); + } + + private static string GetTestEsteid2018CaInBase64() => + Convert.ToBase64String(Certificates.GetTestEsteid2018Ca().GetRawCertData()); } } diff --git a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion1ValidatorTest.cs b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion1ValidatorTest.cs index 87e958a..1e5fdf7 100644 --- a/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion1ValidatorTest.cs +++ b/src/WebEid.Security.Tests/Validator/VersionValidators/AuthTokenVersion1ValidatorTest.cs @@ -137,5 +137,23 @@ public void WhenUnverifiedSigningCertificatesPresentForV1ThenValidationFails() ex.Message, Does.Contain("'unverifiedSigningCertificates' field is not allowed for format 'web-eid:1'")); } + + [Test] + public void WhenUnverifiedIntermediateCertificatesPresentForV1ThenValidationFails() + { + var token = new WebEidAuthToken + { + Format = "web-eid:1", + UnverifiedSigningCertificates = null, + UnverifiedIntermediateCertificates = ["intermediate"] + }; + + var ex = Assert.ThrowsAsync(() => + validator.Validate(token, "nonce")); + + Assert.That( + ex.Message, + Does.Contain("'unverifiedIntermediateCertificates' field is not allowed for format 'web-eid:1'")); + } } } diff --git a/src/WebEid.Security.Tests/WebEid.Security.Tests.csproj b/src/WebEid.Security.Tests/WebEid.Security.Tests.csproj index 40d8818..de8104f 100644 --- a/src/WebEid.Security.Tests/WebEid.Security.Tests.csproj +++ b/src/WebEid.Security.Tests/WebEid.Security.Tests.csproj @@ -24,6 +24,8 @@ + + @@ -40,6 +42,8 @@ + + diff --git a/src/WebEid.Security/AuthToken/UnverifiedSigningCertificate.cs b/src/WebEid.Security/AuthToken/UnverifiedSigningCertificate.cs index 63d2c8f..1869882 100644 --- a/src/WebEid.Security/AuthToken/UnverifiedSigningCertificate.cs +++ b/src/WebEid.Security/AuthToken/UnverifiedSigningCertificate.cs @@ -33,6 +33,11 @@ public class UnverifiedSigningCertificate /// public string Certificate { get; set; } /// + /// The base64-encoded DER-encoded intermediate CA certificates that may be needed to build the + /// signing certificate's certification path to a trusted CA. + /// + public List IntermediateCertificates { get; set; } + /// /// List of supported signature algorithms from the card. /// public List SupportedSignatureAlgorithms { get; set; } diff --git a/src/WebEid.Security/AuthToken/WebEidAuthToken.cs b/src/WebEid.Security/AuthToken/WebEidAuthToken.cs index 75fcaf4..8eee4f8 100644 --- a/src/WebEid.Security/AuthToken/WebEidAuthToken.cs +++ b/src/WebEid.Security/AuthToken/WebEidAuthToken.cs @@ -47,6 +47,11 @@ public class WebEidAuthToken /// public string UnverifiedCertificate { get; set; } /// + /// The base64-encoded DER-encoded intermediate CA certificates that may be needed to build the + /// authentication certificate's certification path to a trusted CA. + /// + public List UnverifiedIntermediateCertificates { get; set; } + /// /// The base64-encoded signing certificates (DER). /// public List UnverifiedSigningCertificates { get; set; } diff --git a/src/WebEid.Security/Util/IntermediateRevocationCheck.cs b/src/WebEid.Security/Util/IntermediateRevocationCheck.cs new file mode 100644 index 0000000..7717f1a --- /dev/null +++ b/src/WebEid.Security/Util/IntermediateRevocationCheck.cs @@ -0,0 +1,40 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Util +{ + /// + /// Selects whether the non-anchor intermediate CA certificates of the built certification path are checked + /// for revocation. The revocation policy of the validated certificate itself is always the caller's + /// responsibility. + /// + public enum IntermediateRevocationCheck + { + /// + /// Non-anchor intermediate CA certificates of the built certification path are checked for revocation. + /// + Enabled, + /// + /// Non-anchor intermediate CA certificates of the built certification path are not checked for revocation. + /// + Disabled + } +} diff --git a/src/WebEid.Security/Util/X509CertificateExtensions.cs b/src/WebEid.Security/Util/X509CertificateExtensions.cs index bf49bbd..659e0bb 100644 --- a/src/WebEid.Security/Util/X509CertificateExtensions.cs +++ b/src/WebEid.Security/Util/X509CertificateExtensions.cs @@ -38,6 +38,8 @@ namespace WebEid.Security.Util /// public static class X509CertificateExtensions { + private static readonly TimeSpan DefaultRevocationUrlRetrievalTimeout = TimeSpan.FromSeconds(5); + /// /// Checks whether the certificate was valid on the given date. /// @@ -74,23 +76,85 @@ public static void ValidateCertificateExpiry(this Org.BouncyCastle.X509.X509Cert /// /// The certificate to validate. /// A collection of trusted CA certificates. - /// The validated certificate if it is signed by a trusted CA. + /// The certificate that directly issued the given certificate; the trust anchor when the anchor is the direct issuer. /// If the certificate is not signed by a trusted CA or if any other error occurs. /// when a CA certificate in the chain or the user certificate is not yet valid /// when a CA certificate in the chain or the user certificate is expired - public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Certificate2 certificate, ICollection trustedCaCertificates) + public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Certificate2 certificate, ICollection trustedCaCertificates) => + ValidateIsValidAndSignedByTrustedCa(certificate, "User", trustedCaCertificates, [], + IntermediateRevocationCheck.Disabled, DateTimeProvider.UtcNow); + + /// + /// Validates that the given certificate is valid and signed by a trusted CA and returns the certificate + /// that directly issued it. + /// + /// The certificate whose certification path is validated. + /// The role of the certificate, e.g. "User" or "AIA OCSP responder", used in + /// validity failure messages. + /// A collection of trusted CA certificates. + /// Untrusted intermediate certificates offered as + /// certification-path candidates only; the path must still terminate at one of the trusted CA certificates. + /// Whether the non-anchor intermediate CA certificates of the built + /// path are checked for revocation. + /// Validation date. + /// The certificate that directly issued the given certificate; the trust anchor when the anchor + /// is the direct issuer. + /// If the certificate is not signed by a trusted CA or if any other error occurs. + /// when a CA certificate in the chain or the user certificate is not yet valid + /// when a CA certificate in the chain or the user certificate is expired + public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Certificate2 certificate, + string certificateSubject, + ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, + IntermediateRevocationCheck intermediateRevocationCheck, + DateTime now) => + ValidateIsValidAndSignedByTrustedCa(certificate, certificateSubject, trustedCaCertificates, + additionalIntermediateCertificates, intermediateRevocationCheck, + DefaultRevocationUrlRetrievalTimeout, now); + + /// + /// Validates that the given certificate is valid and signed by a trusted CA and returns the certificate + /// that directly issued it. + /// + /// The certificate whose certification path is validated. + /// The role of the certificate, e.g. "User" or "AIA OCSP responder", used in + /// validity failure messages. + /// A collection of trusted CA certificates. + /// Untrusted intermediate certificates offered as + /// certification-path candidates only; the path must still terminate at one of the trusted CA certificates. + /// Whether the non-anchor intermediate CA certificates of the built + /// path are checked for revocation. + /// Maximum time spent retrieving OCSP or CRL data while checking + /// intermediate certificate revocation. + /// Validation date. + /// The certificate that directly issued the given certificate; the trust anchor when the anchor + /// is the direct issuer. + /// If the certificate is not signed by a trusted CA or if any other error occurs. + /// when a CA certificate in the chain or the user certificate is not yet valid + /// when a CA certificate in the chain or the user certificate is expired + public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Certificate2 certificate, + string certificateSubject, + ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, + IntermediateRevocationCheck intermediateRevocationCheck, + TimeSpan revocationUrlRetrievalTimeout, + DateTime now) { - ValidateCertificateExpiry(certificate, DateTimeProvider.UtcNow, "User"); + ValidateCertificateExpiry(certificate, now, certificateSubject); + RequirePositiveRevocationUrlRetrievalTimeout(revocationUrlRetrievalTimeout); var chain = new X509Chain { ChainPolicy = { + // Revocation checking of the validated certificate is intentionally disabled here: each caller + // applies its own role-specific leaf revocation policy. Non-anchor intermediate certificates are + // checked separately below when the intermediate revocation check is enabled. RevocationMode = X509RevocationMode.NoCheck, RevocationFlag = X509RevocationFlag.ExcludeRoot, VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority, - VerificationTime = DateTimeProvider.UtcNow, - UrlRetrievalTimeout = TimeSpan.Zero, + VerificationTime = now, + UrlRetrievalTimeout = revocationUrlRetrievalTimeout, DisableCertificateDownloads = true } }; @@ -99,6 +163,10 @@ public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Cert { chain.ChainPolicy.ExtraStore.Add(cert); } + foreach (var cert in additionalIntermediateCertificates ?? []) + { + chain.ChainPolicy.ExtraStore.Add(cert); + } try { @@ -117,25 +185,132 @@ public static X509Certificate2 ValidateIsValidAndSignedByTrustedCa(this X509Cert throw new CertificateNotTrustedException(certificate, certificateErrorsString); } - var chainElement = chain.ChainElements - .Cast() - .FirstOrDefault(x => trustedCaCertificates.Any(ca => - x.Certificate.Thumbprint == ca.Thumbprint)); - if (chainElement?.Certificate == null) + // The built chain is ordered from the subject towards the root. The first element that is + // a trusted CA certificate is the trust anchor; the path must terminate at it. + var trustedCaIndex = -1; + for (var i = 0; i < chain.ChainElements.Count; i++) + { + if (trustedCaCertificates.Any(ca => chain.ChainElements[i].Certificate.Thumbprint == ca.Thumbprint)) + { + trustedCaIndex = i; + break; + } + } + if (trustedCaIndex < 0) { throw new CertificateNotTrustedException(certificate); } + var trustedCaCertificate = chain.ChainElements[trustedCaIndex].Certificate; + + if (intermediateRevocationCheck == IntermediateRevocationCheck.Enabled) + { + ValidateIntermediateCertificatesNotRevoked(chain, trustedCaIndex, trustedCaCertificates, + additionalIntermediateCertificates, revocationUrlRetrievalTimeout, now); + } + // Verify that the trusted CA cert is presently valid before returning the result. - ValidateCertificateExpiry(chainElement.Certificate, DateTimeProvider.UtcNow, "Trusted CA"); + ValidateCertificateExpiry(trustedCaCertificate, now, "Trusted CA"); - return chainElement.Certificate; + // Index 1 (when present before the anchor) is the subject's direct issuer; otherwise the subject + // was issued directly by the trust anchor. + return trustedCaIndex > 0 ? chain.ChainElements[1].Certificate : trustedCaCertificate; } - catch (Exception ex) when (ex is not CertificateNotTrustedException) + catch (Exception ex) when (ex is not CertificateNotTrustedException + and not CertificateExpiredException + and not CertificateNotYetValidException) { throw new CertificateNotTrustedException(certificate, ex); } } + /// + /// Validates that the non-anchor intermediate CA certificates of the built certification path are not revoked. + /// + private static void ValidateIntermediateCertificatesNotRevoked(X509Chain builtChain, + int trustedCaIndex, + ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, + TimeSpan revocationUrlRetrievalTimeout, + DateTime now) + { + if (trustedCaIndex <= 1) + { + return; // The leaf chains directly to a trust anchor; there is no non-anchor intermediate to validate. + } + + // Validate only the CA suffix of the built path, excluding the leaf at index 0, whose revocation policy + // is role-specific and applied by the caller, and the trust anchor, which is excluded from the check + // by treating it as the custom trusted root of the revocation chain. + var firstIntermediateCertificate = builtChain.ChainElements[1].Certificate; + + using var revocationChain = new X509Chain(); + ConfigureIntermediateRevocationPolicy(revocationChain.ChainPolicy, revocationUrlRetrievalTimeout, now); + + foreach (var cert in trustedCaCertificates) + { + revocationChain.ChainPolicy.CustomTrustStore.Add(cert); + revocationChain.ChainPolicy.ExtraStore.Add(cert); + } + foreach (var cert in additionalIntermediateCertificates ?? []) + { + revocationChain.ChainPolicy.ExtraStore.Add(cert); + } + + if (!revocationChain.Build(firstIntermediateCertificate)) + { + var offendingCertificate = GetOffendingCertificate(revocationChain, firstIntermediateCertificate); + var errors = revocationChain.ChainStatus + .Select(x => string.Format(CultureInfo.InvariantCulture, + "{0} ({1})", + x.StatusInformation.Trim(), + x.Status)) + .ToArray(); + var certificateErrorsString = errors.Length > 0 + ? string.Join(Environment.NewLine, errors) + : "Unknown errors."; + + throw new CertificateNotTrustedException(offendingCertificate, certificateErrorsString); + } + } + + internal static void ConfigureIntermediateRevocationPolicy(X509ChainPolicy chainPolicy, + TimeSpan revocationUrlRetrievalTimeout, + DateTime now) + { + ArgumentNullException.ThrowIfNull(chainPolicy); + RequirePositiveRevocationUrlRetrievalTimeout(revocationUrlRetrievalTimeout); + + // Revocation checking prefers OCSP and falls back to CRLs. Hard-fail is deliberately retained: an + // intermediate whose revocation status cannot be established must not become part of a trusted path. + chainPolicy.RevocationMode = X509RevocationMode.Online; + chainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot; + chainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chainPolicy.VerificationTime = now; + chainPolicy.UrlRetrievalTimeout = revocationUrlRetrievalTimeout; + chainPolicy.DisableCertificateDownloads = true; + } + + private static void RequirePositiveRevocationUrlRetrievalTimeout(TimeSpan revocationUrlRetrievalTimeout) + { + if (revocationUrlRetrievalTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(revocationUrlRetrievalTimeout), + "Intermediate certificate revocation URL retrieval timeout must be greater than zero"); + } + } + + /// + /// Returns the intermediate certificate that failed the revocation check, or the first intermediate when + /// the failing certificate cannot be determined. + /// + private static X509Certificate2 GetOffendingCertificate(X509Chain revocationChain, X509Certificate2 firstIntermediateCertificate) + { + var offendingElement = revocationChain.ChainElements + .Cast() + .FirstOrDefault(element => element.ChainElementStatus.Length > 0); + return offendingElement?.Certificate ?? firstIntermediateCertificate; + } + /// /// Parses a base64-encoded certificate and returns an instance. /// @@ -156,6 +331,22 @@ public static X509Certificate2 ParseCertificate(string certificateInBase64, stri } } + /// + /// Parses a list of base64-encoded certificates and returns a list of instances. + /// + /// The base64-encoded certificates. + /// The name of the field containing the certificates. + /// A list of instances. + /// Thrown when parsing fails. + public static List ParseCertificates(ICollection certificatesInBase64, string fieldName) + { + if (certificatesInBase64 == null || certificatesInBase64.Count == 0) + { + return []; + } + return [.. certificatesInBase64.Select(certificate => ParseCertificate(certificate, fieldName))]; + } + /// /// Gets the Common Name (CN) from the certificate's subject. /// diff --git a/src/WebEid.Security/Validator/AuthTokenValidationConfiguration.cs b/src/WebEid.Security/Validator/AuthTokenValidationConfiguration.cs index 0045c66..28f2a63 100644 --- a/src/WebEid.Security/Validator/AuthTokenValidationConfiguration.cs +++ b/src/WebEid.Security/Validator/AuthTokenValidationConfiguration.cs @@ -47,6 +47,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) DesignatedOcspServiceConfiguration = other.DesignatedOcspServiceConfiguration; DisallowedSubjectCertificatePolicies = new ReadOnlyCollection(other.DisallowedSubjectCertificatePolicies); NonceDisabledOcspUrls = [.. other.NonceDisabledOcspUrls]; + AiaOcspResponderIssuerMatchingPolicy = other.AiaOcspResponderIssuerMatchingPolicy; } /// @@ -66,7 +67,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) public bool IsUserCertificateRevocationCheckWithOcspEnabled { get; set; } = true; /// - /// The OCSP request timeout. + /// The network timeout for OCSP and CRL revocation requests. /// public TimeSpan OcspRequestTimeout { get; set; } = TimeSpan.FromSeconds(5); @@ -104,6 +105,13 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) /// public List NonceDisabledOcspUrls { get; } = []; + /// + /// The policy for matching the issuer authorizing an AIA OCSP responder against the subject certificate's + /// issuer. The default is . + /// + public ResponderIssuerMatchingPolicy AiaOcspResponderIssuerMatchingPolicy { get; set; } = + ResponderIssuerMatchingPolicy.ExactCertificate; + private static void RequirePositiveTimeSpan(TimeSpan timeSpan, string fieldName) { @@ -185,7 +193,8 @@ public bool Equals(AuthTokenValidationConfiguration other) MaxOcspResponseThisUpdateAge.Equals(other.MaxOcspResponseThisUpdateAge) && Equals(DesignatedOcspServiceConfiguration, other.DesignatedOcspServiceConfiguration) && Enumerable.SequenceEqual(DisallowedSubjectCertificatePolicies, other.DisallowedSubjectCertificatePolicies) && - Enumerable.SequenceEqual(NonceDisabledOcspUrls, other.NonceDisabledOcspUrls); + Enumerable.SequenceEqual(NonceDisabledOcspUrls, other.NonceDisabledOcspUrls) && + AiaOcspResponderIssuerMatchingPolicy == other.AiaOcspResponderIssuerMatchingPolicy; } /// @@ -199,6 +208,7 @@ public override int GetHashCode() => HashCode.Combine( OcspRequestTimeout, AllowedOcspResponseTimeSkew, MaxOcspResponseThisUpdateAge, - DesignatedOcspServiceConfiguration); + DesignatedOcspServiceConfiguration, + AiaOcspResponderIssuerMatchingPolicy); } } diff --git a/src/WebEid.Security/Validator/AuthTokenValidator.cs b/src/WebEid.Security/Validator/AuthTokenValidator.cs index 7ba20cc..99d0d32 100644 --- a/src/WebEid.Security/Validator/AuthTokenValidator.cs +++ b/src/WebEid.Security/Validator/AuthTokenValidator.cs @@ -41,7 +41,7 @@ public sealed class AuthTokenValidator : IAuthTokenValidator // Use human-readable meaningful names for token length limits. private const int TokenMinLength = 100; - private const int TokenMaxLength = 10000; + private const int TokenMaxLength = 65536; /// /// Initializes a new instance of the class. diff --git a/src/WebEid.Security/Validator/AuthTokenValidatorBuilder.cs b/src/WebEid.Security/Validator/AuthTokenValidatorBuilder.cs index 8d7f5a1..4d1dd8d 100644 --- a/src/WebEid.Security/Validator/AuthTokenValidatorBuilder.cs +++ b/src/WebEid.Security/Validator/AuthTokenValidatorBuilder.cs @@ -116,10 +116,11 @@ public AuthTokenValidatorBuilder WithoutUserCertificateRevocationCheckWithOcsp() } /// - /// Sets both the connection and response timeout of user certificate revocation check OCSP requests. + /// Sets the network timeout for certificate revocation requests. This applies to user certificate OCSP + /// requests and platform OCSP or CRL retrievals used to check intermediate certificates. /// This is an optional configuration parameter, the default is 5 seconds. /// - /// the duration of OCSP request connection and response timeout + /// the certificate revocation request timeout /// the builder instance for method chaining public AuthTokenValidatorBuilder WithOcspRequestTimeout(TimeSpan ocspRequestTimeout) { @@ -185,6 +186,25 @@ public AuthTokenValidatorBuilder WithNonceDisabledOcspUrls(params Uri[] urls) return this; } + /// + /// Sets how an AIA OCSP responder certificate issuer is matched against the issuer of the subject certificate. + /// The default is . Use + /// only when equivalent cross-certificates must be + /// accepted. Under that policy, non-anchor intermediate certificates in the responder's certification path are + /// checked for revocation. + /// + /// AIA OCSP responder issuer matching policy + /// the builder instance for method chaining + public AuthTokenValidatorBuilder WithAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy matchingPolicy) + { + configuration.AiaOcspResponderIssuerMatchingPolicy = matchingPolicy; + if (logger?.IsEnabled(LogLevel.Debug) == true) + { + logger.LogDebug("AIA OCSP responder issuer matching policy set to {MatchingPolicy}", matchingPolicy); + } + return this; + } + /// /// Activates the provided designated OCSP service for user certificate revocation check with OCSP. /// The designated service is only used for checking the status of the certificates whose issuers are diff --git a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateNotRevokedValidator.cs b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateNotRevokedValidator.cs index 3b44216..d614759 100644 --- a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateNotRevokedValidator.cs +++ b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateNotRevokedValidator.cs @@ -22,7 +22,9 @@ namespace WebEid.Security.Validator.CertValidators { using System; + using System.Collections.Generic; using System.IO; + using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Exceptions; @@ -40,11 +42,13 @@ internal sealed class SubjectCertificateNotRevokedValidator(SubjectCertificateTr OcspServiceProvider ocspServiceProvider, TimeSpan allowedOcspTimeSkew, TimeSpan maxOcspResponseThisUpdateAge, + ICollection additionalIntermediateCertificates, ILogger logger = null) : ISubjectCertificateValidator { private readonly SubjectCertificateTrustedValidator trustValidator = trustValidator; private readonly IOcspClient ocspClient = ocspClient; private readonly OcspServiceProvider ocspServiceProvider = ocspServiceProvider; + private readonly ICollection additionalIntermediateCertificates = additionalIntermediateCertificates ?? []; private readonly ILogger logger = logger; private readonly TimeSpan allowedOcspTimeSkew = allowedOcspTimeSkew; @@ -60,7 +64,9 @@ public async Task Validate(X509Certificate2 subjectCertificate) try { var certificate = DotNetUtilities.FromX509Certificate(subjectCertificate); - var ocspService = ocspServiceProvider.GetService(certificate); + var ocspService = ocspServiceProvider.GetService(certificate, + trustValidator.SubjectCertificateIssuerCertificate, + additionalIntermediateCertificates); if (!ocspService.DoesSupportNonce) { @@ -117,7 +123,17 @@ private void VerifyOcspResponse(BasicOcspResp basicResponse, $"OCSP response must contain one response, received {basicResponse.Responses.Length} responses instead"); } var certStatusResponse = basicResponse.Responses[0]; - if (!requestCertificateId.SerialNumber.Equals(certStatusResponse.GetCertID().SerialNumber)) + // Confirm the whole certificate ID corresponds to the requested one, not only the serial number: + // a serial number is unique only within a single issuer, so the issuer name and key hashes must + // be compared as well to guard against a response about a same-serial certificate of a different + // issuer. The hash algorithm is compared by OID rather than by its full AlgorithmIdentifier, so + // that responders that encode the SHA-1 parameters as an explicit NULL are treated as equal to + // those that omit the parameters (the two DER forms are equivalent per RFC 6960 CertID). + var responseCertificateId = certStatusResponse.GetCertID(); + if (!requestCertificateId.SerialNumber.Equals(responseCertificateId.SerialNumber) + || !string.Equals(requestCertificateId.HashAlgOid, responseCertificateId.HashAlgOid, StringComparison.Ordinal) + || !requestCertificateId.GetIssuerNameHash().SequenceEqual(responseCertificateId.GetIssuerNameHash()) + || !requestCertificateId.GetIssuerKeyHash().SequenceEqual(responseCertificateId.GetIssuerKeyHash())) { throw new UserCertificateOcspCheckFailedException( "OCSP responded with certificate ID that differs from the requested ID"); diff --git a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateTrustedValidator.cs b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateTrustedValidator.cs index 67363e4..87d2070 100644 --- a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateTrustedValidator.cs +++ b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateTrustedValidator.cs @@ -21,6 +21,7 @@ */ namespace WebEid.Security.Validator.CertValidators { + using System; using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; @@ -28,11 +29,28 @@ namespace WebEid.Security.Validator.CertValidators using Util; using WebEid.Security.Exceptions; - internal sealed class SubjectCertificateTrustedValidator(ICollection trustedCaCertificates, ILogger logger) : ISubjectCertificateValidator + internal sealed class SubjectCertificateTrustedValidator(ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, + TimeSpan revocationUrlRetrievalTimeout, + ILogger logger) : ISubjectCertificateValidator { private readonly ICollection trustedCaCertificates = trustedCaCertificates; + private readonly ICollection additionalIntermediateCertificates = additionalIntermediateCertificates ?? []; + private readonly TimeSpan revocationUrlRetrievalTimeout = revocationUrlRetrievalTimeout; private readonly ILogger logger = logger; + internal SubjectCertificateTrustedValidator(ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, + ILogger logger) + : this(trustedCaCertificates, additionalIntermediateCertificates, TimeSpan.FromSeconds(5), logger) + { + } + + internal SubjectCertificateTrustedValidator(ICollection trustedCaCertificates, ILogger logger) + : this(trustedCaCertificates, [], TimeSpan.FromSeconds(5), logger) + { + } + /// /// Checks that the user certificate from the authentication token is valid and signed by /// a trusted certificate authority. Also checks the validity of the user certificate's @@ -44,13 +62,25 @@ internal sealed class SubjectCertificateTrustedValidator(ICollectionwhen a CA certificate in the chain or the user certificate is expired public Task Validate(X509Certificate2 subjectCertificate) { - SubjectCertificateIssuerCertificate = subjectCertificate.ValidateIsValidAndSignedByTrustedCa(trustedCaCertificates); + SubjectCertificateIssuerCertificate = subjectCertificate.ValidateIsValidAndSignedByTrustedCa( + "User", + trustedCaCertificates, + additionalIntermediateCertificates, + // Intermediate CA certificates require revocation checks here because they are not checked elsewhere. + // Subject certificate revocation is handled separately by SubjectCertificateNotRevokedValidator. + IntermediateRevocationCheck.Enabled, + revocationUrlRetrievalTimeout, + DateTimeProvider.UtcNow); logger?.LogDebug("Subject certificate is valid and signed by a trusted CA"); return Task.CompletedTask; } + /// + /// Returns the certificate that directly issued the subject certificate, or the trust anchor when the anchor + /// is the direct issuer. Available after has succeeded. + /// public X509Certificate2 SubjectCertificateIssuerCertificate { get; private set; } } } diff --git a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateValidatorBatch.cs b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateValidatorBatch.cs index 710f26d..a3e2ab1 100644 --- a/src/WebEid.Security/Validator/CertValidators/SubjectCertificateValidatorBatch.cs +++ b/src/WebEid.Security/Validator/CertValidators/SubjectCertificateValidatorBatch.cs @@ -65,6 +65,7 @@ public async Task ExecuteFor(X509Certificate2 subjectCertificate) public static SubjectCertificateValidatorBatch ForTrustValidation( AuthTokenValidationConfiguration configuration, ICollection trustedCaCertificates, + ICollection additionalIntermediateCertificates, IOcspClient ocspClient, OcspServiceProvider ocspServiceProvider, ILogger logger) @@ -82,7 +83,8 @@ public static SubjectCertificateValidatorBatch ForTrustValidation( } } - var certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCaCertificates, logger); + var certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCaCertificates, + additionalIntermediateCertificates, configuration.OcspRequestTimeout, logger); var batch = CreateFrom(certTrustedValidator); if (configuration.IsUserCertificateRevocationCheckWithOcspEnabled) @@ -94,6 +96,7 @@ public static SubjectCertificateValidatorBatch ForTrustValidation( ocspServiceProvider, configuration.AllowedOcspResponseTimeSkew, configuration.MaxOcspResponseThisUpdateAge, + additionalIntermediateCertificates, logger ) ); diff --git a/src/WebEid.Security/Validator/Ocsp/OcspResponseValidator.cs b/src/WebEid.Security/Validator/Ocsp/OcspResponseValidator.cs index 90f8bbf..c4c0f11 100644 --- a/src/WebEid.Security/Validator/Ocsp/OcspResponseValidator.cs +++ b/src/WebEid.Security/Validator/Ocsp/OcspResponseValidator.cs @@ -47,7 +47,7 @@ public static void ValidateHasSigningExtension(X509Certificate certificate) if (extendedKeyUsage == null || !extendedKeyUsage.Contains(OidOcspSigning)) { throw new OcspCertificateException( - $"Certificate {certificate.SubjectDN} does not contain the key usage extension for OCSP response signing"); + $"Certificate {certificate.SubjectDN} does not contain the extended key usage extension value for OCSP response signing"); } } catch (CertificateParsingException e) diff --git a/src/WebEid.Security/Validator/Ocsp/OcspServiceProvider.cs b/src/WebEid.Security/Validator/Ocsp/OcspServiceProvider.cs index a59e683..fd28f49 100644 --- a/src/WebEid.Security/Validator/Ocsp/OcspServiceProvider.cs +++ b/src/WebEid.Security/Validator/Ocsp/OcspServiceProvider.cs @@ -22,6 +22,8 @@ namespace WebEid.Security.Validator.Ocsp { using System; + using System.Collections.Generic; + using System.Security.Cryptography.X509Certificates; using Service; /// @@ -40,16 +42,24 @@ public class OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOc /// /// Gets the appropriate OCSP service based on the certificate issuer. + /// An AIA OCSP service instance is created for a single validation run of the given certificate. /// /// The X.509 certificate for which to retrieve the OCSP service. + /// The certificate that directly issued the subject certificate. + /// Untrusted, token-supplied intermediate certificates that may be + /// needed to build the OCSP responder's certification path to a trusted CA; may be empty. /// An instance of . - public IOcspService GetService(Org.BouncyCastle.X509.X509Certificate certificate = null) + public IOcspService GetService(Org.BouncyCastle.X509.X509Certificate certificate, + X509Certificate2 certificateIssuerCertificate, + ICollection additionalIntermediateCertificates) { if (designatedOcspService != null && designatedOcspService.SupportsIssuerOf(certificate)) { + // The designated responder is pinned by equality, so the subject issuer and token-supplied + // intermediate certificates are not needed for its validation. return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, certificateIssuerCertificate, additionalIntermediateCertificates); } } } diff --git a/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspService.cs b/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspService.cs index 030e776..afbafd4 100644 --- a/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspService.cs +++ b/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspService.cs @@ -23,6 +23,7 @@ namespace WebEid.Security.Validator.Ocsp.Service { using System; using System.Collections.Generic; + using System.Linq; using System.Security.Cryptography.X509Certificates; using Exceptions; using Org.BouncyCastle.Security; @@ -34,15 +35,36 @@ namespace WebEid.Security.Validator.Ocsp.Service internal class AiaOcspService : IOcspService { private readonly List trustedCaCertificates; + private readonly X509Certificate2 certificateIssuerCertificate; + private readonly ICollection additionalIntermediateCertificates; + private readonly ResponderIssuerMatchingPolicy responderIssuerMatchingPolicy; + private readonly TimeSpan revocationUrlRetrievalTimeout; + /// + /// Creates an AIA OCSP service for a single validation run of the given certificate. + /// + /// AIA OCSP service configuration. + /// The certificate whose revocation status the service answers for. + /// The certificate that directly issued the given certificate; + /// the OCSP responder must be authorized by it. + /// Untrusted, token-supplied intermediate certificates that + /// may be needed to build the responder's certification path to a trusted CA; may be empty. public AiaOcspService(AiaOcspServiceConfiguration configuration, - Org.BouncyCastle.X509.X509Certificate certificate) + Org.BouncyCastle.X509.X509Certificate certificate, + X509Certificate2 certificateIssuerCertificate, + ICollection additionalIntermediateCertificates) { ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(certificateIssuerCertificate); + ArgumentNullException.ThrowIfNull(additionalIntermediateCertificates); AccessLocation = GetOcspAiaUrlFromCertificate(certificate); trustedCaCertificates = configuration.TrustedCaCertificates; + this.certificateIssuerCertificate = certificateIssuerCertificate; + this.additionalIntermediateCertificates = additionalIntermediateCertificates; DoesSupportNonce = !configuration.NonceDisabledOcspUrls.Contains(AccessLocation); + responderIssuerMatchingPolicy = configuration.ResponderIssuerMatchingPolicy; + revocationUrlRetrievalTimeout = configuration.RevocationUrlRetrievalTimeout; } public bool DoesSupportNonce { get; } @@ -61,16 +83,82 @@ public void ValidateResponderCertificate(Org.BouncyCastle.X509.X509Certificate r { try { - responderCertificate.ValidateCertificateExpiry(now, "AIA OCSP responder"); - // Trusted certificates validity has been already verified in ValidateCertificateExpiry(). + var certificate = new X509Certificate2(DotNetUtilities.ToX509Certificate(responderCertificate)); + // The responder certificate's validity on the current date is checked as part of the certification + // path validation. A responder may be issued by a token-supplied intermediate that is not itself + // trusted, so the intermediates are offered as path candidates; the path must still terminate at a + // trusted anchor. The responder certificate itself is never revocation-checked, whatever revocation + // policy the CA has chosen for it under RFC 6960 section 4.2.2.2.1: OCSP-checking a responder against + // its own service would be circular. In practice all production Estonian, Belgian and Finnish AIA + // responder certificates carry id-pkix-ocsp-nocheck, which tells clients to skip the check anyway. + // + // With exact issuer matching, the intermediate CA certificates are not checked again: this validation + // run has already vetted the exact issuer while validating the subject certificate, as either a + // configured trust anchor or a token-supplied intermediate that was revocation-checked then. With + // subject-and-public-key matching, however, the responder path may use a different equivalent + // cross-certificate, so every non-anchor intermediate in that path is revocation-checked. + var responderIssuerCertificate = certificate.ValidateIsValidAndSignedByTrustedCa( + "AIA OCSP responder", + trustedCaCertificates, + additionalIntermediateCertificates, + GetIntermediateRevocationCheck(), + revocationUrlRetrievalTimeout, + now); + // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the subject certificate + // or by a responder directly delegated by it; a locally configured responder is handled by + // DesignatedOcspService. + if (MatchesCertificateIssuer(certificate, certificateIssuerCertificate)) + { + // The response is signed by the issuing CA itself; the OCSP-signing extended key usage is required + // only for delegated responder certificates. + return; + } + if (RepresentsSameCa(certificate, certificateIssuerCertificate)) + { + // The response is signed directly by the issuing CA, but with a certificate that is only equivalent + // to (same subject and public key), not identical with, the subject certificate's issuer certificate. + // This can only happen under the ExactCertificate policy. Report it explicitly, because otherwise + // control falls through to the delegated-responder branch below and fails with a misleading + // missing-OCSP-signing-extended-key-usage error; the SubjectAndPublicKey policy accepts it. + throw new CertificateNotTrustedException(certificate, + "OCSP response is signed by a certificate equivalent to but not the same as the subject " + + "certificate issuer; the exact-certificate responder issuer matching policy requires the " + + "issuer certificate itself"); + } OcspResponseValidator.ValidateHasSigningExtension(responderCertificate); - _ = new X509Certificate2(DotNetUtilities.ToX509Certificate(responderCertificate)) - .ValidateIsValidAndSignedByTrustedCa(trustedCaCertificates); + if (!MatchesCertificateIssuer(responderIssuerCertificate, certificateIssuerCertificate)) + { + throw new CertificateNotTrustedException(certificate, + "OCSP responder is not authorized by the subject certificate issuer"); + } } - catch (Exception ex) when (ex is not CertificateNotTrustedException and not CertificateExpiredException) + catch (Exception ex) when (ex is not CertificateNotTrustedException + and not CertificateExpiredException + and not CertificateNotYetValidException + and not OcspCertificateException) { throw new OcspCertificateException("Invalid certificate", ex); } } + + private static bool RepresentsSameCa(X509Certificate2 first, X509Certificate2 second) => + first.SubjectName.RawData.SequenceEqual(second.SubjectName.RawData) && + first.PublicKey.ExportSubjectPublicKeyInfo().SequenceEqual(second.PublicKey.ExportSubjectPublicKeyInfo()); + + private bool MatchesCertificateIssuer(X509Certificate2 first, X509Certificate2 second) => + responderIssuerMatchingPolicy switch + { + ResponderIssuerMatchingPolicy.ExactCertificate => first.RawData.SequenceEqual(second.RawData), + ResponderIssuerMatchingPolicy.SubjectAndPublicKey => RepresentsSameCa(first, second), + _ => throw new InvalidOperationException($"Unknown responder issuer matching policy {responderIssuerMatchingPolicy}") + }; + + private IntermediateRevocationCheck GetIntermediateRevocationCheck() => + responderIssuerMatchingPolicy switch + { + ResponderIssuerMatchingPolicy.ExactCertificate => IntermediateRevocationCheck.Disabled, + ResponderIssuerMatchingPolicy.SubjectAndPublicKey => IntermediateRevocationCheck.Enabled, + _ => throw new InvalidOperationException($"Unknown responder issuer matching policy {responderIssuerMatchingPolicy}") + }; } } diff --git a/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspServiceConfiguration.cs b/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspServiceConfiguration.cs index 0c218f8..3af3c42 100644 --- a/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspServiceConfiguration.cs +++ b/src/WebEid.Security/Validator/Ocsp/Service/AiaOcspServiceConfiguration.cs @@ -34,7 +34,14 @@ namespace WebEid.Security.Validator.Ocsp.Service /// /// List of OCSP responder URLs where nonce is disabled. /// List of trusted CA certificates. - public sealed class AiaOcspServiceConfiguration(List nonceDisabledOcspUrls, List trustedCaCertificates) : IEquatable + /// How the issuer authorizing an AIA OCSP responder is matched + /// against the subject certificate's issuer. + /// Maximum time spent retrieving OCSP or CRL data while checking + /// intermediate certificate revocation. + public sealed class AiaOcspServiceConfiguration(List nonceDisabledOcspUrls, + List trustedCaCertificates, + ResponderIssuerMatchingPolicy responderIssuerMatchingPolicy = ResponderIssuerMatchingPolicy.ExactCertificate, + TimeSpan? revocationUrlRetrievalTimeout = null) : IEquatable { /// @@ -49,6 +56,19 @@ public sealed class AiaOcspServiceConfiguration(List nonceDisabledOcspUrls, public List TrustedCaCertificates { get; } = trustedCaCertificates ?? throw new ArgumentNullException(nameof(trustedCaCertificates)); + /// + /// Gets the policy for matching the issuer authorizing an AIA OCSP responder against the subject + /// certificate's issuer. + /// + public ResponderIssuerMatchingPolicy ResponderIssuerMatchingPolicy { get; } = responderIssuerMatchingPolicy; + + /// + /// Gets the maximum time spent retrieving OCSP or CRL data while checking intermediate certificate + /// revocation. + /// + public TimeSpan RevocationUrlRetrievalTimeout { get; } = + RequirePositiveTimeout(revocationUrlRetrievalTimeout ?? TimeSpan.FromSeconds(5)); + /// /// Determines whether the current instance is equal to another . /// @@ -67,7 +87,9 @@ public bool Equals(AiaOcspServiceConfiguration other) } return Enumerable.SequenceEqual(NonceDisabledOcspUrls, other.NonceDisabledOcspUrls) && - Enumerable.SequenceEqual(TrustedCaCertificates, other.TrustedCaCertificates); + Enumerable.SequenceEqual(TrustedCaCertificates, other.TrustedCaCertificates) && + ResponderIssuerMatchingPolicy == other.ResponderIssuerMatchingPolicy && + RevocationUrlRetrievalTimeout == other.RevocationUrlRetrievalTimeout; } /// @@ -78,11 +100,19 @@ public override int GetHashCode() { unchecked { - return ((NonceDisabledOcspUrls != null ? NonceDisabledOcspUrls.GetHashCode() : 0) * 397) ^ - (TrustedCaCertificates != null ? TrustedCaCertificates.GetHashCode() : 0); + var hashCode = NonceDisabledOcspUrls != null ? NonceDisabledOcspUrls.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ (TrustedCaCertificates != null ? TrustedCaCertificates.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ ResponderIssuerMatchingPolicy.GetHashCode(); + return (hashCode * 397) ^ RevocationUrlRetrievalTimeout.GetHashCode(); } } + private static TimeSpan RequirePositiveTimeout(TimeSpan revocationUrlRetrievalTimeout) => + revocationUrlRetrievalTimeout > TimeSpan.Zero + ? revocationUrlRetrievalTimeout + : throw new ArgumentOutOfRangeException(nameof(revocationUrlRetrievalTimeout), + "Intermediate certificate revocation URL retrieval timeout must be greater than zero"); + /// /// Determines whether two instances are equal. /// diff --git a/src/WebEid.Security/Validator/Ocsp/Service/ResponderIssuerMatchingPolicy.cs b/src/WebEid.Security/Validator/Ocsp/Service/ResponderIssuerMatchingPolicy.cs new file mode 100644 index 0000000..329cc56 --- /dev/null +++ b/src/WebEid.Security/Validator/Ocsp/Service/ResponderIssuerMatchingPolicy.cs @@ -0,0 +1,40 @@ +/* + * Copyright © 2025-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +namespace WebEid.Security.Validator.Ocsp.Service +{ + /// + /// Determines how the issuer authorizing an AIA OCSP responder is matched against the subject certificate's + /// issuer. + /// + public enum ResponderIssuerMatchingPolicy + { + /// + /// Requires the same encoded X.509 certificate. + /// + ExactCertificate, + /// + /// Accepts different certificates that contain the same subject and public key. Non-anchor intermediate + /// certificates in the responder's certification path are checked for revocation. + /// + SubjectAndPublicKey + } +} diff --git a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion11Validator.cs b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion11Validator.cs index feec139..867aa5f 100644 --- a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion11Validator.cs +++ b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion11Validator.cs @@ -74,9 +74,6 @@ public sealed partial class AuthTokenVersion11Validator : AuthTokenVersion1Valid }; private readonly AuthTokenValidationConfiguration configuration; - private readonly IOcspClient ocspClient; - private readonly OcspServiceProvider ocspServiceProvider; - private readonly ILogger logger; /// /// Initializes a validator for Web eID authentication tokens in format web-eid:1.1. @@ -90,12 +87,7 @@ internal AuthTokenVersion11Validator( ILogger logger = null) : base(simpleSubjectCertificateValidators, signatureValidator, configuration, ocspClient, ocspServiceProvider, logger) - { - this.configuration = configuration; - this.ocspClient = ocspClient; - this.ocspServiceProvider = ocspServiceProvider; - this.logger = logger; - } + => this.configuration = configuration; /// protected override Regex GetSupportedFormatPattern() => @@ -108,15 +100,16 @@ protected override Regex GetSupportedFormatPattern() => public override async Task Validate(WebEidAuthToken authToken, string currentChallengeNonce) { var subjectCertificate = await base.Validate(authToken, currentChallengeNonce); - var signingCertificates = ValidateSigningCertificates(authToken); - foreach (var signingCertificate in signingCertificates) + foreach (var unverifiedSigningCertificate in ValidateSigningCertificates(authToken)) { + var signingCertificate = ParseSigningCertificate(unverifiedSigningCertificate.Certificate); ValidateSameSubject(subjectCertificate, signingCertificate); ValidateSameIssuer(subjectCertificate, signingCertificate); - ValidateSigningCertificateValidity(signingCertificate); ValidateSigningCertificateKeyUsage(signingCertificate); - await ValidateSigningCertificateChainAsync(signingCertificate); + ValidateSigningCertificateChain(signingCertificate, + X509CertificateExtensions.ParseCertificates( + unverifiedSigningCertificate.IntermediateCertificates, "intermediateCertificates")); } return subjectCertificate; @@ -147,18 +140,23 @@ private static void ValidateSupportedSignatureAlgorithms(UnverifiedSigningCertif } } - private static List ValidateSigningCertificates(WebEidAuthToken token) + private static List ValidateSigningCertificates(WebEidAuthToken token) { var signingCertificates = token.UnverifiedSigningCertificates; + var intermediateCertificates = token.UnverifiedIntermediateCertificates; + // When the authentication certificate's intermediate certificates are present, signing certificates + // are optional. + if (signingCertificates == null && intermediateCertificates is { Count: > 0 }) + { + return []; + } if (signingCertificates == null || signingCertificates.Count == 0) { throw new AuthTokenParseException( "'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); } - var result = new List(); - foreach (var certificate in signingCertificates) { if (certificate == null || string.IsNullOrEmpty(certificate.Certificate)) @@ -168,21 +166,23 @@ private static List ValidateSigningCertificates(WebEidAuthToke } ValidateSupportedSignatureAlgorithms(certificate); - - try - { - result.Add( - X509CertificateExtensions.ParseCertificate( - certificate.Certificate, - "unverifiedSigningCertificates")); - } - catch (Exception ex) - { - throw new AuthTokenParseException("Failed to decode signing certificate", ex); - } + ValidateIntermediateCertificatesField(certificate.IntermediateCertificates, + "intermediateCertificates", token.Format); } - return result; + return signingCertificates; + } + + private static X509Certificate2 ParseSigningCertificate(string certificateInBase64) + { + try + { + return X509CertificateExtensions.ParseCertificate(certificateInBase64, "unverifiedSigningCertificates"); + } + catch (Exception ex) + { + throw new AuthTokenParseException("Failed to decode signing certificate", ex); + } } private static void ValidateSameSubject(X509Certificate2 subjectCert, X509Certificate2 signingCert) @@ -208,20 +208,6 @@ private static void ValidateSameIssuer(X509Certificate2 subjectCert, X509Certifi } } - private static void ValidateSigningCertificateValidity(X509Certificate2 cert) - { - try - { - var bcCert = cert.ToBouncyCastle(); - bcCert.ValidateCertificateExpiry(DateTime.UtcNow, "signing_certificate"); - } - catch (Exception ex) - { - throw new AuthTokenParseException( - "Signing certificate is not valid: " + ex.Message, ex); - } - } - private static void ValidateSigningCertificateKeyUsage(X509Certificate2 cert) { var keyUsage = cert.Extensions.OfType().FirstOrDefault(); @@ -233,19 +219,21 @@ private static void ValidateSigningCertificateKeyUsage(X509Certificate2 cert) } } - private async Task ValidateSigningCertificateChainAsync(X509Certificate2 signingCertificate) + private void ValidateSigningCertificateChain(X509Certificate2 signingCertificate, + ICollection intermediateCertificates) { try { - var trustValidators = SubjectCertificateValidatorBatch.ForTrustValidation( - configuration, + // The signing certificate itself deliberately gets no revocation check during authentication: its + // revocation status matters at signing time and is the signature validation service's concern. + // Token-supplied intermediate certificates in its path are checked for revocation. + signingCertificate.ValidateIsValidAndSignedByTrustedCa( + "Signing", configuration.TrustedCaCertificates, - ocspClient, - ocspServiceProvider, - logger - ); - - await trustValidators.ExecuteFor(signingCertificate); + intermediateCertificates, + IntermediateRevocationCheck.Enabled, + configuration.OcspRequestTimeout, + DateTimeProvider.UtcNow); } catch (Exception ex) { diff --git a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion1Validator.cs b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion1Validator.cs index 425bff1..f6f62f5 100644 --- a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion1Validator.cs +++ b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersion1Validator.cs @@ -22,6 +22,8 @@ namespace WebEid.Security.Validator.VersionValidators { using System; + using System.Collections.Generic; + using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -86,9 +88,16 @@ protected virtual Regex GetSupportedFormatPattern() => /// public virtual async Task Validate(WebEidAuthToken authToken, string currentChallengeNonce) { - if (IsExactV10Format(authToken.Format) && authToken.UnverifiedSigningCertificates != null) + if (IsExactV10Format(authToken.Format)) { - throw new AuthTokenParseException($"'unverifiedSigningCertificates' field is not allowed for format '{authToken.Format}'"); + if (authToken.UnverifiedSigningCertificates != null) + { + throw new AuthTokenParseException($"'unverifiedSigningCertificates' field is not allowed for format '{authToken.Format}'"); + } + if (authToken.UnverifiedIntermediateCertificates != null) + { + throw new AuthTokenParseException($"'unverifiedIntermediateCertificates' field is not allowed for format '{authToken.Format}'"); + } } if (string.IsNullOrEmpty(authToken.UnverifiedCertificate)) @@ -100,12 +109,14 @@ public virtual async Task Validate(WebEidAuthToken authToken, authToken.UnverifiedCertificate, "unverifiedCertificate" ); + var additionalIntermediateCertificates = DecodeAdditionalIntermediateCertificates(authToken); await simpleSubjectCertificateValidators.ExecuteFor(subjectCertificate); var trustValidators = SubjectCertificateValidatorBatch.ForTrustValidation( configuration, configuration.TrustedCaCertificates, + additionalIntermediateCertificates, ocspClient, ocspServiceProvider, logger @@ -127,5 +138,29 @@ public virtual async Task Validate(WebEidAuthToken authToken, private static bool IsExactV10Format(string format) => V1_SUPPORTED_TOKEN_FORMAT_PREFIX.Equals(format, StringComparison.OrdinalIgnoreCase) || "web-eid:1.0".Equals(format, StringComparison.OrdinalIgnoreCase); + + private static List DecodeAdditionalIntermediateCertificates(WebEidAuthToken token) + { + ValidateIntermediateCertificatesField(token.UnverifiedIntermediateCertificates, + "unverifiedIntermediateCertificates", token.Format); + return X509CertificateExtensions.ParseCertificates(token.UnverifiedIntermediateCertificates, + "unverifiedIntermediateCertificates"); + } + + internal static void ValidateIntermediateCertificatesField(List intermediateCertificates, string fieldName, string format) + { + if (intermediateCertificates == null) + { + return; + } + if (intermediateCertificates.Count == 0) + { + throw new AuthTokenParseException($"'{fieldName}' must not be empty for format '{format}'"); + } + if (intermediateCertificates.Any(string.IsNullOrEmpty)) + { + throw new AuthTokenParseException($"'{fieldName}' must not contain null or empty entries for format '{format}'"); + } + } } } diff --git a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersionValidatorFactory.cs b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersionValidatorFactory.cs index d71f66c..d69529b 100644 --- a/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersionValidatorFactory.cs +++ b/src/WebEid.Security/Validator/VersionValidators/AuthTokenVersionValidatorFactory.cs @@ -89,7 +89,9 @@ public static AuthTokenVersionValidatorFactory Create( validationConfig.DesignatedOcspServiceConfiguration, new AiaOcspServiceConfiguration( validationConfig.NonceDisabledOcspUrls, - trustedCaCertificates + trustedCaCertificates, + validationConfig.AiaOcspResponderIssuerMatchingPolicy, + validationConfig.OcspRequestTimeout ) ); }