diff --git a/README.md b/README.md index e3aa6986..77b957d4 100644 --- a/README.md +++ b/README.md @@ -309,12 +309,52 @@ It contains the following fields: - `signature`: the base64-encoded signature of the token (see the description below), -- `format`: the type identifier and version of the token format separated by a colon character '`:`', `web-eid:1.0` as of now; the version number consists of the major and minor number separated by a dot, major version changes are incompatible with previous versions, minor version changes are backwards-compatible within the given major version, +- `format`: the type identifier and version of the token format separated by a colon character '`:`', either `web-eid:1.0` or `web-eid:1.1`; the version number consists of the major and minor number separated by a dot, major version changes are incompatible with previous versions, minor version changes are backwards-compatible within the given major version, - `appVersion`: the URL identifying the name and version of the application that issued the token; informative purpose, can be used to identify the affected application in case of faulty tokens. The value that is signed by the user’s authentication private key and included in the `signature` field is `hash(origin)+hash(challenge)`. The hash function is used before concatenation to ensure field separation as the hash of a value is guaranteed to have a fixed length. Otherwise the origin `example.com` with challenge nonce `.eu1234` and another origin `example.com.eu` with challenge nonce `1234` would result in the same value after concatenation. The hash function `hash` is the same hash function that is used in the signature algorithm, for example SHA256 in case of RS256. +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, as well as the eID user's signing certificates. An extended token looks like the following example: + +```json +{ + "unverifiedCertificate": "MIIFozCCA4ugAwIBAgIQHFpdK-zCQsFW4...", + "unverifiedIntermediateCertificates": ["MIIFfhzYIBAAwIBAgIQHFHFp-AwIBFW4..."], + "algorithm": "RS256", + "signature": "HBjNXIaUskXbfhzYQHvwjKDUWfNu4yxXZha...", + "unverifiedSigningCertificates": [ + { + "certificate": "MIIFoXIaUskXbfhzYIBAgIjKDUsdK-zQHFUKz...", + "intermediateCertificates": ["MIIFfhzYIBAAwIBAgIQHFHFp-AwIBFW4..."], + "supportedSignatureAlgorithms": [ + { + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-384", + "paddingScheme": "NONE" + } + ] + } + ], + "format": "web-eid:1.1", + "appVersion": "https://web-eid.eu/web-eid-app/releases/v2.0.0" +} +``` + +In addition to the fields described above, the `web-eid:1.1` format defines the following additional fields: + +- `unverifiedIntermediateCertificates`: an array of base64-encoded DER intermediate CA certificates that make up the trust chain of the authentication certificate in `unverifiedCertificate`. Like the authentication certificate, these certificates are received from the client side and cannot be trusted; they are only used as candidate certificates when building the certification path, which must still terminate at a trusted certificate authority. Every intermediate selected for the path is revocation-checked with OCSP or CRLs and the token is rejected if its status cannot be determined. The field is optional, but when present it must not be empty. When it is present, `unverifiedSigningCertificates` may be omitted. + +- `unverifiedSigningCertificates`: an array of the eID user's signing certificates, presented alongside the authentication certificate. For `web-eid:1.1` tokens this field is required unless `unverifiedIntermediateCertificates` is present, and when present it must not be empty. Each entry contains: + + - `certificate`: the base64-encoded DER signing certificate. During validation it must have the same subject and issuer as the authentication certificate, be valid, contain the non-repudiation key usage bit and be signed by a trusted certificate authority. + + - `intermediateCertificates`: an optional array of base64-encoded DER intermediate CA certificates that make up the trust chain of this signing certificate. When present it must not be empty and, as with `unverifiedIntermediateCertificates`, the certificates are only used as candidates when building the certification path to a trusted CA and every selected intermediate is revocation-checked. + + - `supportedSignatureAlgorithms`: the signature algorithms that the signing certificate's key supports. Each entry has a `cryptoAlgorithm` (`ECC` or `RSA`), a `hashFunction` (one of `SHA-224`, `SHA-256`, `SHA-384`, `SHA-512`, `SHA3-224`, `SHA3-256`, `SHA3-384`, `SHA3-512`) and a `paddingScheme` (`NONE`, `PKCS1.5` or `PSS`). + +The signature is always created with the authentication private key and verified using `unverifiedCertificate`; the signing certificates are not involved in the signature verification. + # Authentication token validation @@ -323,6 +363,8 @@ 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. +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 website back end 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 back end 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 diff --git a/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java b/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java index 745600f8..7731b9f0 100644 --- a/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java +++ b/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java @@ -30,6 +30,7 @@ public class UnverifiedSigningCertificate { private String certificate; + private List intermediateCertificates; private List supportedSignatureAlgorithms; public String getCertificate() { @@ -40,6 +41,14 @@ public void setCertificate(String certificate) { this.certificate = certificate; } + public List getIntermediateCertificates() { + return intermediateCertificates; + } + + public void setIntermediateCertificates(List intermediateCertificates) { + this.intermediateCertificates = intermediateCertificates; + } + public List getSupportedSignatureAlgorithms() { return supportedSignatureAlgorithms; } diff --git a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java index a476446e..0bd4845b 100644 --- a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java +++ b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java @@ -34,6 +34,7 @@ public class WebEidAuthToken { private String algorithm; private String format; + private List unverifiedIntermediateCertificates; private List unverifiedSigningCertificates; public String getUnverifiedCertificate() { @@ -44,6 +45,14 @@ public void setUnverifiedCertificate(String unverifiedCertificate) { this.unverifiedCertificate = unverifiedCertificate; } + public List getUnverifiedIntermediateCertificates() { + return unverifiedIntermediateCertificates; + } + + public void setUnverifiedIntermediateCertificates(List unverifiedIntermediateCertificates) { + this.unverifiedIntermediateCertificates = unverifiedIntermediateCertificates; + } + public String getSignature() { return signature; } diff --git a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java index 0da0a670..c7f9ff38 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java @@ -63,6 +63,17 @@ public static X509Certificate decodeCertificateFromBase64(String certificateInBa } } + public static List decodeCertificatesFromBase64(List certificatesInBase64) throws CertificateDecodingException { + if (certificatesInBase64 == null || certificatesInBase64.isEmpty()) { + return List.of(); + } + final List decodedCertificates = new ArrayList<>(); + for (final String certificateInBase64 : certificatesInBase64) { + decodedCertificates.add(decodeCertificateFromBase64(certificateInBase64)); + } + return decodedCertificates; + } + private CertificateLoader() { throw new IllegalStateException("Utility class"); } diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index 8a6701b3..f46f4da8 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -30,17 +30,26 @@ import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; +import java.security.cert.CertPath; import java.security.cert.CertPathBuilder; import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; import java.security.cert.CertStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.security.cert.CollectionCertStoreParameters; import java.security.cert.PKIXBuilderParameters; import java.security.cert.PKIXCertPathBuilderResult; +import java.security.cert.PKIXParameters; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.TrustAnchor; import java.security.cert.X509CertSelector; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -60,6 +69,14 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { + return validateIsSignedByTrustedCA(certificate, trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), now); + } + + public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certificate, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, + Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { certificateIsValidOnDate(certificate, now, "User"); final X509CertSelector selector = new X509CertSelector(); @@ -71,25 +88,79 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif pkixBuilderParameters.setRevocationEnabled(false); pkixBuilderParameters.setDate(now); pkixBuilderParameters.addCertStore(trustedCACertificateCertStore); + if (additionalIntermediateCertificates != null && !additionalIntermediateCertificates.isEmpty()) { + pkixBuilderParameters.addCertStore(buildCertStoreFromCertificates(additionalIntermediateCertificates)); + } // See the comment in buildCertStoreFromCertificates() below why we use the default JCE provider. final CertPathBuilder certPathBuilder = CertPathBuilder.getInstance(CertPathBuilder.getDefaultType()); final PKIXCertPathBuilderResult result = (PKIXCertPathBuilderResult) certPathBuilder.build(pkixBuilderParameters); + validateIntermediateCertificatesNotRevoked( + result, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + additionalIntermediateCertificates, + now + ); + final X509Certificate trustedCACert = result.getTrustAnchor().getTrustedCert(); // Verify that the trusted CA cert is presently valid before returning the result. certificateIsValidOnDate(trustedCACert, now, "Trusted CA"); - return trustedCACert; + return getIssuerCertificate(result, trustedCACert); - } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { + } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException | CertificateException e) { throw new JceException(e); - } catch (CertPathBuilderException e) { + } catch (CertPathBuilderException | CertPathValidatorException e) { throw new CertificateNotTrustedException(certificate, e); } } + private static void validateIntermediateCertificatesNotRevoked( + PKIXCertPathBuilderResult pathBuilderResult, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, + Date now + ) throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, CertificateException, + CertPathValidatorException { + final List certificatePath = pathBuilderResult.getCertPath().getCertificates(); + if (certificatePath.size() <= 1) { + return; // leaf chains directly to a trust anchor; no non-anchor intermediate to validate + } + + // The existing application OCSP flow checks the end-entity certificate. Validate only the CA suffix here, + // excluding both the end entity at index 0 and the trust anchor, which is not part of the built path. + final CertPath intermediateCertificatePath = CertificateFactory.getInstance("X.509") + .generateCertPath(certificatePath.subList(1, certificatePath.size())); + final PKIXParameters revocationCheckingParameters = new PKIXParameters(trustedCACertificateAnchors); + revocationCheckingParameters.setDate(now); + // An explicitly added checker is active regardless of this flag and avoids installing a second default checker. + revocationCheckingParameters.setRevocationEnabled(false); + revocationCheckingParameters.addCertStore(trustedCACertificateCertStore); + if (additionalIntermediateCertificates != null && !additionalIntermediateCertificates.isEmpty()) { + revocationCheckingParameters.addCertStore(CertStore.getInstance( + "Collection", new CollectionCertStoreParameters(additionalIntermediateCertificates))); + } + + final CertPathValidator certPathValidator = CertPathValidator.getInstance(CertPathValidator.getDefaultType()); + final PKIXRevocationChecker revocationChecker = + (PKIXRevocationChecker) certPathValidator.getRevocationChecker(); + // The default checker prefers OCSP and falls back to CRLs. SOFT_FAIL is deliberately not enabled: a token- + // supplied intermediate whose revocation status cannot be established must not become part of a trusted path. + revocationCheckingParameters.addCertPathChecker(revocationChecker); + certPathValidator.validate(intermediateCertificatePath, revocationCheckingParameters); + } + + private static X509Certificate getIssuerCertificate(PKIXCertPathBuilderResult path, X509Certificate trustedCACert) { + // The built path is ordered from the subject towards the anchor and excludes the anchor, so index 1 (when + // present) is the subject's direct issuer; otherwise the subject was issued directly by the trust anchor. + final List certificatePath = path.getCertPath().getCertificates(); + return certificatePath.size() > 1 ? (X509Certificate) certificatePath.get(1) : trustedCACert; + } + public static Set buildTrustAnchorsFromCertificates(Collection certificates) { return certificates.stream() .map(cert -> new TrustAnchor(cert, null)).collect(Collectors.toUnmodifiableSet()); diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java index 76404a5a..148de9e0 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java @@ -48,7 +48,7 @@ final class AuthTokenValidatorManager implements AuthTokenValidator { // Use human-readable meaningful names for token length limits. private static final int TOKEN_MIN_LENGTH = 100; - private static final int TOKEN_MAX_LENGTH = 10000; + private static final int TOKEN_MAX_LENGTH = 65536; private static final ObjectReader TOKEN_READER = new ObjectMapper().readerFor(WebEidAuthToken.class); diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java index c460d405..aaf7c534 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java @@ -165,7 +165,11 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); - ocspService.validateResponderCertificate(responderCert, now); + ocspService.validateResponderCertificate( + responderCert, + trustValidator.getSubjectCertificateIssuerCertificate(), + trustValidator.getAdditionalIntermediateCertificates(), + now); // 5. The time at which the status being indicated is known to be // correct (thisUpdate) is sufficiently recent. diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index a706ffc4..180df033 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -35,6 +35,7 @@ import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; import java.util.Set; public final class SubjectCertificateTrustedValidator { @@ -43,11 +44,17 @@ public final class SubjectCertificateTrustedValidator { private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final List additionalIntermediateCertificates; private X509Certificate subjectCertificateIssuerCertificate; public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this(trustedCACertificateAnchors, trustedCACertificateCertStore, List.of()); + } + + public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, List additionalIntermediateCertificates) { this.trustedCACertificateAnchors = trustedCACertificateAnchors; this.trustedCACertificateCertStore = trustedCACertificateCertStore; + this.additionalIntermediateCertificates = additionalIntermediateCertificates; } /** @@ -67,6 +74,7 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw subjectCertificate, trustedCACertificateAnchors, trustedCACertificateCertStore, + additionalIntermediateCertificates, now ); LOG.debug("Subject certificate is valid and signed by a trusted CA"); @@ -75,4 +83,8 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw public X509Certificate getSubjectCertificateIssuerCertificate() { return subjectCertificateIssuerCertificate; } + + public List getAdditionalIntermediateCertificates() { + return additionalIntermediateCertificates; + } } diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java index 7fd3bc36..edfd80e3 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java @@ -74,11 +74,12 @@ public static SubjectCertificateValidatorBatch forTrustValidation( AuthTokenValidationConfiguration configuration, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, OcspClient ocspClient, OcspServiceProvider ocspServiceProvider) { final SubjectCertificateTrustedValidator certTrustedValidator = - new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); + new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore, additionalIntermediateCertificates); return SubjectCertificateValidatorBatch.createFrom( certTrustedValidator::validateCertificateTrusted diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index e04823c3..35685d60 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -24,6 +24,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.OCSPCertificateException; import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.validator.ocsp.OcspResponseValidator; @@ -35,7 +36,9 @@ import java.security.cert.CertificateException; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.Arrays; import java.util.Date; +import java.util.List; import java.util.Objects; import java.util.Set; @@ -71,18 +74,41 @@ public URI getAccessLocation() { } @Override - public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + public void validateResponderCertificate(X509CertificateHolder cert, + X509Certificate subjectCertificateIssuerCertificate, + List additionalIntermediateCertificates, + Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); CertificateValidator.certificateIsValidOnDate(certificate, now, "AIA OCSP responder"); // Trusted certificates' validity has been already verified in validateCertificateExpiry(). OcspResponseValidator.validateHasSigningExtension(certificate); - CertificateValidator.validateIsSignedByTrustedCA(certificate, trustedCACertificateAnchors, trustedCACertificateCertStore, now); + // A CA-designated 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. + final X509Certificate responderIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA( + certificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + additionalIntermediateCertificates, + now + ); + // RFC 6960: the responder must be the issuing CA itself or be directly delegated by it. CA identity is + // compared by subject and public key so that equivalent cross-certificates for the same CA are accepted. + if (!representsSameCA(certificate, subjectCertificateIssuerCertificate) + && !representsSameCA(responderIssuerCertificate, subjectCertificateIssuerCertificate)) { + throw new CertificateNotTrustedException(certificate, + new CertificateException("OCSP responder is not authorized by the subject certificate issuer")); + } } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } } + private static boolean representsSameCA(X509Certificate first, X509Certificate second) { + return first.getSubjectX500Principal().equals(second.getSubjectX500Principal()) + && Arrays.equals(first.getPublicKey().getEncoded(), second.getPublicKey().getEncoded()); + } + private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException { return getOcspUri(certificate).orElseThrow(() -> new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed") diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java index 37974d5f..cb6a746e 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java @@ -32,6 +32,7 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; import java.util.Objects; import static eu.webeid.security.certificate.CertificateValidator.certificateIsValidOnDate; @@ -59,7 +60,12 @@ public URI getAccessLocation() { } @Override - public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + public void validateResponderCertificate(X509CertificateHolder cert, + X509Certificate subjectCertificateIssuerCertificate, + List additionalIntermediateCertificates, + Date now) throws AuthTokenException { + // The designated responder is pinned by equality below, so no certification path is built and the subject + // issuer and token-supplied intermediate certificates are not needed here. try { final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); // Certificate pinning is implemented simply by comparing the certificates or their public keys, diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java index b551071b..b90927d9 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java @@ -26,7 +26,9 @@ import org.bouncycastle.cert.X509CertificateHolder; import java.net.URI; +import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; public interface OcspService { @@ -34,6 +36,18 @@ public interface OcspService { URI getAccessLocation(); - void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; + /** + * Validates that the OCSP responder certificate is trusted and authorized to answer for the subject certificate. + * + * @param cert the responder certificate from the OCSP response + * @param subjectCertificateIssuerCertificate the certificate that directly issued the subject certificate + * @param additionalIntermediateCertificates untrusted, token-supplied intermediate certificates that may be needed + * to build the responder's certification path to a trusted CA; may be empty + * @param now validation date + */ + void validateResponderCertificate(X509CertificateHolder cert, + X509Certificate subjectCertificateIssuerCertificate, + List additionalIntermediateCertificates, + Date now) throws AuthTokenException; } diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java index db39fade..e1a887ca 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java @@ -26,9 +26,13 @@ import eu.webeid.security.authtoken.UnverifiedSigningCertificate; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateLoader; +import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; -import eu.webeid.security.exceptions.CertificateDecodingException; +import eu.webeid.security.exceptions.CertificateExpiredException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.exceptions.CertificateNotYetValidException; +import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.AuthTokenSignatureValidator; import eu.webeid.security.validator.AuthTokenValidationConfiguration; import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; @@ -41,17 +45,11 @@ import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import javax.security.auth.x500.X500Principal; -import java.security.cert.CertPath; -import java.security.cert.CertPathValidator; import java.security.cert.CertStore; -import java.security.cert.CertificateExpiredException; -import java.security.cert.CertificateFactory; -import java.security.cert.CertificateNotYetValidException; -import java.security.cert.PKIXParameters; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -69,9 +67,6 @@ class AuthTokenVersion11Validator extends AuthTokenVersion1Validator implements ); private static final int KEY_USAGE_NON_REPUDIATION = 1; - private final Set trustedCACertificateAnchors; - private final CertStore trustedCACertificateCertStore; - public AuthTokenVersion11Validator( SubjectCertificateValidatorBatch simpleSubjectCertificateValidators, Set trustedCACertificateAnchors, @@ -90,8 +85,6 @@ public AuthTokenVersion11Validator( ocspClient, ocspServiceProvider ); - this.trustedCACertificateAnchors = trustedCACertificateAnchors; - this.trustedCACertificateCertStore = trustedCACertificateCertStore; } @Override @@ -101,16 +94,16 @@ protected Pattern getSupportedFormatPattern() { @Override public X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - final X509Certificate subjectCertificate = validateV1(token, currentChallengeNonce); - final List signingCertificates = validateSigningCertificates(token); - for (X509Certificate signingCertificate : signingCertificates) { - validateSameSubject(subjectCertificate, signingCertificate); - validateSameIssuer(subjectCertificate, signingCertificate); - validateSigningCertificateValidity(signingCertificate); - validateKeyUsage(signingCertificate); - validateSigningCertificateChain(signingCertificate); + validateUnverifiedIntermediateCertificates(token.getUnverifiedIntermediateCertificates(), "unverifiedIntermediateCertificates"); + List intermediateCertificates = CertificateLoader.decodeCertificatesFromBase64(token.getUnverifiedIntermediateCertificates()); + final X509Certificate subjectCertificate = validateV1(token, currentChallengeNonce, intermediateCertificates); + for (final UnverifiedSigningCertificate signingCertificate : validateSigningCertificates(token)) { + final X509Certificate certificate = CertificateLoader.decodeCertificateFromBase64(signingCertificate.getCertificate()); + validateSameSubject(subjectCertificate, certificate); + validateSameIssuer(subjectCertificate, certificate); + validateKeyUsage(certificate); + validateSigningCertificateChain(certificate, CertificateLoader.decodeCertificatesFromBase64(signingCertificate.getIntermediateCertificates())); } - return subjectCertificate; } @@ -136,24 +129,38 @@ private static void validateSupportedSignatureAlgorithms(UnverifiedSigningCertif } } - private static List validateSigningCertificates(WebEidAuthToken token) throws AuthTokenParseException, CertificateDecodingException { - List signingCertificates = token.getUnverifiedSigningCertificates(); + private static List validateSigningCertificates(WebEidAuthToken token) throws AuthTokenParseException { + final List signingCertificates = token.getUnverifiedSigningCertificates(); + final List intermediateCertificates = token.getUnverifiedIntermediateCertificates(); + // When the authentication certificate's intermediate certificates are present, signing certificates are optional. + if (signingCertificates == null && intermediateCertificates != null && !intermediateCertificates.isEmpty()) { + return List.of(); + } if (signingCertificates == null || signingCertificates.isEmpty()) { throw new AuthTokenParseException("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); } - List result = new ArrayList<>(); - - for (UnverifiedSigningCertificate certificate : signingCertificates) { + for (final UnverifiedSigningCertificate certificate : signingCertificates) { if (certificate == null || isNullOrEmpty(certificate.getCertificate())) { - throw new AuthTokenParseException("'unverifiedSigningCertificates' contains a null or empty entry for format 'web-eid:1.1'"); + throw new AuthTokenParseException("'unverifiedSigningCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); } validateSupportedSignatureAlgorithms(certificate); - result.add(CertificateLoader.decodeCertificateFromBase64(certificate.getCertificate())); + validateUnverifiedIntermediateCertificates(certificate.getIntermediateCertificates(), "intermediateCertificates"); } + return signingCertificates; + } - return result; + private static void validateUnverifiedIntermediateCertificates(List intermediateCertificates, String fieldName) throws AuthTokenParseException { + if (intermediateCertificates == null) { + return; + } + if (intermediateCertificates.isEmpty()) { + throw new AuthTokenParseException("'" + fieldName + "' must not be empty for format 'web-eid:1.1'"); + } + if (intermediateCertificates.stream().anyMatch(certificate -> isNullOrEmpty(certificate))) { + throw new AuthTokenParseException("'" + fieldName + "' must not contain null or empty entries for format 'web-eid:1.1'"); + } } private static void validateSameSubject(X509Certificate subjectCertificate, X509Certificate signingCertificate) @@ -178,38 +185,27 @@ private static void validateSameIssuer(X509Certificate subjectCertificate, X509C } } - private static void validateSigningCertificateValidity(X509Certificate signingCertificate) - throws AuthTokenParseException { - try { - signingCertificate.checkValidity(); - } catch (CertificateExpiredException | CertificateNotYetValidException e) { - throw new AuthTokenParseException("Signing certificate is not valid: " + e.getMessage(), e); - } - } - - private static void validateKeyUsage(X509Certificate signingCertificate) - throws AuthTokenParseException { + private static void validateKeyUsage(X509Certificate signingCertificate) throws AuthTokenParseException { boolean[] keyUsage = signingCertificate.getKeyUsage(); if (keyUsage == null || keyUsage.length <= KEY_USAGE_NON_REPUDIATION || !keyUsage[KEY_USAGE_NON_REPUDIATION]) { throw new AuthTokenParseException("Signing certificate key usage extension missing or does not contain non-repudiation bit required for digital signatures"); } } - private void validateSigningCertificateChain(X509Certificate signingCertificate) - throws AuthTokenParseException { + private void validateSigningCertificateChain(X509Certificate signingCertificate, List intermediateCertificates) + throws AuthTokenException { + // Use the clock instance so that the date can be mocked in tests. + final Date now = DateAndTime.DefaultClock.getInstance().now(); try { - CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - - CertPath certPath = certificateFactory.generateCertPath(List.of(signingCertificate)); - - PKIXParameters parameters = new PKIXParameters(trustedCACertificateAnchors); - parameters.addCertStore(trustedCACertificateCertStore); - parameters.setRevocationEnabled(false); - - CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - validator.validate(certPath, parameters); + CertificateValidator.validateIsSignedByTrustedCA( + signingCertificate, + getTrustedCACertificateAnchors(), + getTrustedCACertificateCertStore(), + intermediateCertificates, + now + ); } catch (Exception e) { - throw new AuthTokenParseException("Signing certificate chain validation failed", e); + throw new AuthTokenParseException("Signing certificate validation failed", e); } } @@ -235,7 +231,7 @@ private static byte[] getAuthorityKeyIdentifier(X509Certificate certificate) thr } } - protected X509Certificate validateV1(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - return super.validate(token, currentChallengeNonce); + protected X509Certificate validateV1(WebEidAuthToken token, String currentChallengeNonce, List intermediateCertificates) throws AuthTokenException { + return super.validate(token, currentChallengeNonce, intermediateCertificates); } } diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java index f2c2105a..0db2535c 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java @@ -35,6 +35,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -79,10 +80,21 @@ protected Pattern getSupportedFormatPattern() { @Override public X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - if (isExactV10Format(token.getFormat()) && token.getUnverifiedSigningCertificates() != null) { - throw new AuthTokenParseException( - "'unverifiedSigningCertificates' field is not allowed for format '" + token.getFormat() + "'" - ); + return validate(token, currentChallengeNonce, null); + } + + protected X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce, List intermediateCertificates) throws AuthTokenException { + if (isExactV10Format(token.getFormat())) { + if (token.getUnverifiedSigningCertificates() != null) { + throw new AuthTokenParseException( + "'unverifiedSigningCertificates' field is not allowed for format '" + token.getFormat() + "'" + ); + } + if (token.getUnverifiedIntermediateCertificates() != null) { + throw new AuthTokenParseException( + "'unverifiedIntermediateCertificates' field is not allowed for format '" + token.getFormat() + "'" + ); + } } if (token.getUnverifiedCertificate() == null || token.getUnverifiedCertificate().isEmpty()) { @@ -97,6 +109,7 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo configuration, trustedCACertificateAnchors, trustedCACertificateCertStore, + intermediateCertificates, ocspClient, ocspServiceProvider ).executeFor(subjectCertificate); @@ -116,4 +129,12 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo private static boolean isExactV10Format(String format) { return V1_SUPPORTED_TOKEN_FORMAT_PREFIX.equals(format) || "web-eid:1.0".equals(format); } + + protected Set getTrustedCACertificateAnchors() { + return trustedCACertificateAnchors; + } + + protected CertStore getTrustedCACertificateCertStore() { + return trustedCACertificateCertStore; + } } diff --git a/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java b/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java new file mode 100644 index 00000000..01e5e705 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020-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. + */ + +package eu.webeid.security.certificate; + +import eu.webeid.security.exceptions.CertificateDecodingException; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; + +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class CertificateLoaderTest { + + @Test + void whenNullList_thenReturnsEmptyList() throws Exception { + assertThat(CertificateLoader.decodeCertificatesFromBase64(null)) + .isEmpty(); + } + + @Test + void whenEmptyList_thenReturnsEmptyList() throws Exception { + assertThat(CertificateLoader.decodeCertificatesFromBase64(List.of())) + .isEmpty(); + } + + @Test + void whenValidBase64Certificates_thenDecodesAll() throws Exception { + final X509Certificate firstCert = getJaakKristjanEsteid2018Cert(); + final X509Certificate secondCert = getMariliisEsteid2015Cert(); + + final List decoded = CertificateLoader.decodeCertificatesFromBase64( + List.of(toBase64(firstCert), toBase64(secondCert))); + + assertThat(decoded) + .containsExactly(firstCert, secondCert); + } + + @Test + void whenNotBase64_thenThrows() { + assertThatExceptionOfType(CertificateDecodingException.class) + .isThrownBy(() -> CertificateLoader.decodeCertificatesFromBase64(List.of("not base64!"))); + } + + @Test + void whenBase64ButNotACertificate_thenThrows() { + final String notACertificate = Base64.getEncoder().encodeToString("this is not a certificate".getBytes()); + assertThatExceptionOfType(CertificateDecodingException.class) + .isThrownBy(() -> CertificateLoader.decodeCertificatesFromBase64(List.of(notACertificate))); + } + + private static String toBase64(X509Certificate certificate) throws Exception { + return Base64.getEncoder().encodeToString(certificate.getEncoded()); + } +} diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java new file mode 100644 index 00000000..f2f3166a --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2020-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. + */ + +package eu.webeid.security.certificate; + +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v2CRLBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertStore; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CRL; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CertificateValidatorTest { + + private static final Date NOW = new Date(); + private static final Date NOT_BEFORE = new Date(NOW.getTime() - 86_400_000L); + private static final Date NOT_AFTER = new Date(NOW.getTime() + 86_400_000L); + + private static X509Certificate rootCertificate; + private static X509Certificate intermediateCertificateC; // signed by root + private static X509Certificate intermediateCertificateB; // signed by C + private static X509Certificate intermediateCertificateA; // signed by B, direct issuer of the leaf + private static X509Certificate leafCertificate; // signed by A + private static X509CRL rootCrl; + private static X509CRL intermediateCCrl; + private static X509CRL intermediateBCrl; + private static X509CRL intermediateBRevokingACrl; + + @BeforeAll + static void setUp() throws Exception { + // A single chain: root -> intermediateCertificateC -> intermediateCertificateB -> intermediateCertificateA -> leaf. + final KeyPair rootKeyPair = generateKeyPair(); + final KeyPair intermediateCKeyPair = generateKeyPair(); + final KeyPair intermediateBKeyPair = generateKeyPair(); + final KeyPair intermediateAKeyPair = generateKeyPair(); + final KeyPair leafKeyPair = generateKeyPair(); + + final X500Name rootName = new X500Name("CN=Test Root CA"); + final X500Name intermediateCName = new X500Name("CN=Test Intermediate CA C"); + final X500Name intermediateBName = new X500Name("CN=Test Intermediate CA B"); + final X500Name intermediateAName = new X500Name("CN=Test Intermediate CA A"); + final X500Name leafName = new X500Name("CN=Test Leaf"); + + rootCertificate = generateCertificate(rootName, rootKeyPair.getPublic(), + rootName, rootKeyPair.getPrivate(), rootKeyPair.getPublic(), true, BigInteger.valueOf(1)); + intermediateCertificateC = generateCertificate(intermediateCName, intermediateCKeyPair.getPublic(), + rootName, rootKeyPair.getPrivate(), rootKeyPair.getPublic(), true, BigInteger.valueOf(2)); + intermediateCertificateB = generateCertificate(intermediateBName, intermediateBKeyPair.getPublic(), + intermediateCName, intermediateCKeyPair.getPrivate(), intermediateCKeyPair.getPublic(), true, BigInteger.valueOf(3)); + intermediateCertificateA = generateCertificate(intermediateAName, intermediateAKeyPair.getPublic(), + intermediateBName, intermediateBKeyPair.getPrivate(), intermediateBKeyPair.getPublic(), true, BigInteger.valueOf(4)); + leafCertificate = generateCertificate(leafName, leafKeyPair.getPublic(), + intermediateAName, intermediateAKeyPair.getPrivate(), intermediateAKeyPair.getPublic(), false, BigInteger.valueOf(5)); + + rootCrl = generateCrl(rootName, rootKeyPair.getPrivate()); + intermediateCCrl = generateCrl(intermediateCName, intermediateCKeyPair.getPrivate()); + intermediateBCrl = generateCrl(intermediateBName, intermediateBKeyPair.getPrivate()); + intermediateBRevokingACrl = generateCrl( + intermediateBName, intermediateBKeyPair.getPrivate(), intermediateCertificateA.getSerialNumber()); + } + + @Test + void whenChainHasTokenSuppliedIntermediate_thenReturnsDirectIssuerNotTrustAnchor() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore revocationStore = buildCrlStore(rootCrl, intermediateCCrl, intermediateBCrl); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, revocationStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), 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). + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @Test + void whenSubjectIssuedDirectlyByTrustAnchor_thenReturnsTrustAnchor() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateA, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, emptyStore, Collections.emptyList(), NOW); + + // Single-hop chain: the direct issuer is the trust anchor itself. + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @Test + void whenChainHasMultipleTokenSuppliedIntermediatesAndGrandparentIsPinned_thenValidationSucceeds() throws Exception { + // Token supplies the full A -> B -> C intermediate chain; 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). + final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateC, null)); + final CertStore revocationStore = buildCrlStore(intermediateCCrl, intermediateBCrl); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, revocationStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW); + + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @Test + void whenTokenSuppliedIntermediateIsRevoked_thenRejectsCertificateChain() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore revocationStore = buildCrlStore( + rootCrl, intermediateCCrl, intermediateBRevokingACrl); + + assertThat(intermediateBRevokingACrl.isRevoked(intermediateCertificateA)).isTrue(); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, revocationStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + .satisfies(exception -> { + assertThat(exception.getCause()).isInstanceOf(CertPathValidatorException.class); + final CertPathValidatorException validationException = + (CertPathValidatorException) exception.getCause(); + assertThat(validationException.getReason()) + .isEqualTo(CertPathValidatorException.BasicReason.REVOKED); + }); + } + + @Test + void whenTokenSuppliedIntermediateRevocationStatusIsUnknown_thenRejectsCertificateChain() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, emptyStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + .hasCauseInstanceOf(CertPathValidatorException.class); + } + + private static KeyPair generateKeyPair() throws Exception { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate(X500Name subject, PublicKey subjectPublicKey, + X500Name issuer, PrivateKey issuerPrivateKey, PublicKey issuerPublicKey, + boolean ca, BigInteger serial) throws Exception { + final JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, NOT_BEFORE, NOT_AFTER, subject, subjectPublicKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca)); + builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); + if (ca) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private static X509CRL generateCrl(X500Name issuer, PrivateKey issuerPrivateKey, + BigInteger... revokedCertificateSerials) throws Exception { + final X509v2CRLBuilder builder = new X509v2CRLBuilder(issuer, NOT_BEFORE); + builder.setNextUpdate(NOT_AFTER); + for (final BigInteger serial : revokedCertificateSerials) { + builder.addCRLEntry(serial, NOT_BEFORE, 0); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CRLConverter().getCRL(builder.build(signer)); + } + + private static CertStore buildCrlStore(X509CRL... crls) throws Exception { + return CertStore.getInstance("Collection", new CollectionCertStoreParameters(List.of(crls))); + } +} diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java index 4b38f822..a484b770 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java @@ -58,7 +58,7 @@ void whenTokenTooShort_thenParsingFails() { @Test void whenTokenTooLong_thenParsingFails() { assertThatThrownBy(() -> validator - .parse(new String(new char[10001]))) + .parse(new String(new char[65537]))) .isInstanceOf(AuthTokenParseException.class) .hasMessage("Auth token is too long"); } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java index c038f2a2..5fb86e5f 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import java.net.URI; +import java.util.Collections; import java.util.Date; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; @@ -50,10 +51,12 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); assertThat(service.doesSupportNonce()).isTrue(); assertThatCode(() -> - service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), new Date(1630000000000L))) + service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), + getTestEsteid2018CA(), Collections.emptyList(), new Date(1630000000000L))) .doesNotThrowAnyException(); assertThatCode(() -> - service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) + service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), + getTestEsteid2018CA(), Collections.emptyList(), new Date(1630000000000L))) .isInstanceOf(OCSPCertificateException.class) .hasMessage("Responder certificate from the OCSP response is not equal to the configured designated OCSP responder certificate"); } @@ -66,7 +69,8 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws assertThat(service2018.doesSupportNonce()).isTrue(); assertThatCode(() -> // Use the CA certificate instead of responder certificate for convenience. - service2018.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) + service2018.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), + getTestEsteid2018CA(), Collections.emptyList(), new Date(1630000000000L))) .doesNotThrowAnyException(); final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert()); @@ -74,7 +78,8 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws assertThat(service2015.doesSupportNonce()).isFalse(); assertThatCode(() -> // Use the CA certificate instead of responder certificate for convenience. - service2015.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2015CA().getEncoded()), new Date(1630000000000L))) + service2015.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2015CA().getEncoded()), + getTestEsteid2015CA(), Collections.emptyList(), new Date(1630000000000L))) .doesNotThrowAnyException(); } @@ -85,7 +90,8 @@ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows final X509CertificateHolder wrongResponderCert = new X509CertificateHolder(getMariliisEsteid2015Cert().getEncoded()); assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> - service2018.validateResponderCertificate(wrongResponderCert, new Date(1630000000000L))); + service2018.validateResponderCertificate(wrongResponderCert, getTestEsteid2018CA(), + Collections.emptyList(), new Date(1630000000000L))); } } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java new file mode 100644 index 00000000..8048eb01 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2020-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. + */ + +package eu.webeid.security.validator.ocsp.service; + +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.validator.ocsp.OcspServiceProvider; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AccessDescription; +import org.bouncycastle.asn1.x509.AuthorityInformationAccess; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v2CRLBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertStore; +import java.security.cert.CertificateException; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CRL; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Verifies AIA responder path building through token-supplied intermediates and the authorization boundary between + * CA-delegated AIA responders and explicitly configured designated responders. + */ +class AiaOcspServiceTest { + + private static final Date NOW = new Date(); + private static final Date NOT_BEFORE = new Date(NOW.getTime() - 86_400_000L); + private static final Date NOT_AFTER = new Date(NOW.getTime() + 86_400_000L); + private static final String OCSP_URL = "http://ocsp.example/responder"; + + private static X509Certificate intermediateCertificate; + private static X509Certificate crossIntermediateCertificate; + private static X509Certificate responderCertificate; + private static X509Certificate rootIssuedResponderCertificate; + private static X509Certificate siblingIntermediateCertificate; + private static X509Certificate siblingResponderCertificate; + private static X509Certificate subjectCertificate; + private static AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + private static AiaOcspService aiaOcspService; + + @BeforeAll + static void setUp() throws Exception { + final KeyPair rootKeyPair = generateKeyPair(); + final KeyPair intermediateKeyPair = generateKeyPair(); + final KeyPair responderKeyPair = generateKeyPair(); + final KeyPair rootIssuedResponderKeyPair = generateKeyPair(); + final KeyPair siblingIntermediateKeyPair = generateKeyPair(); + final KeyPair siblingResponderKeyPair = generateKeyPair(); + final KeyPair subjectKeyPair = generateKeyPair(); + + final X509Certificate rootCertificate = generateCertificate( + "Test Root CA", rootKeyPair.getPublic(), "Test Root CA", rootKeyPair, 1, true, false, null); + intermediateCertificate = generateCertificate( + "Test Intermediate CA", intermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 2, true, false, null); + // 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. + crossIntermediateCertificate = generateCertificate( + "Test Intermediate CA", intermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 7, true, false, null); + // The OCSP responder is delegated by the intermediate CA (RFC 6960 CA-designated responder). + responderCertificate = generateCertificate( + "Test OCSP Responder", responderKeyPair.getPublic(), "Test Intermediate CA", intermediateKeyPair, 3, false, true, null); + // 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, criterion 1). + rootIssuedResponderCertificate = generateCertificate( + "Root-Issued OCSP Responder", rootIssuedResponderKeyPair.getPublic(), "Test Root CA", + rootKeyPair, 8, false, true, null); + siblingIntermediateCertificate = generateCertificate( + "Sibling Intermediate CA", siblingIntermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 5, true, false, null); + siblingResponderCertificate = generateCertificate( + "Sibling OCSP Responder", siblingResponderKeyPair.getPublic(), "Sibling Intermediate CA", + siblingIntermediateKeyPair, 6, false, true, null); + // The subject certificate is only needed so that AiaOcspService can read the AIA OCSP URL from it. + subjectCertificate = generateCertificate( + "Test Subject", subjectKeyPair.getPublic(), "Test Intermediate CA", intermediateKeyPair, 4, false, false, OCSP_URL); + + // Trust only the root. The intermediate that issued both the subject and the responder is NOT configured as + // trusted, exactly like a deployment that relies on the token-supplied intermediate to build the chain. + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final X509CRL rootCrl = generateCrl(new X500Name("CN=Test Root CA"), rootKeyPair.getPrivate()); + final CertStore storeWithIntermediateRevocationData = CertStore.getInstance("Collection", + new CollectionCertStoreParameters(List.of(rootCrl))); + aiaOcspServiceConfiguration = new AiaOcspServiceConfiguration( + Collections.emptySet(), anchors, storeWithIntermediateRevocationData); + aiaOcspService = new AiaOcspService(aiaOcspServiceConfiguration, subjectCertificate); + } + + @Test + void whenResponderChainsViaTokenIntermediate_thenValidationSucceeds() throws Exception { + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + assertThatCode(() -> aiaOcspService.validateResponderCertificate( + responderHolder, intermediateCertificate, Collections.singletonList(intermediateCertificate), NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderChainsViaTokenIntermediateButIntermediateMissing_thenValidationFails() throws Exception { + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + // Without the token-supplied intermediate, the responder -> intermediate -> root path cannot be built. + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> aiaOcspService.validateResponderCertificate( + responderHolder, intermediateCertificate, Collections.emptyList(), NOW)); + } + + @Test + void whenResponderIssuerIsEquivalentCrossCertificate_thenValidationSucceeds() throws Exception { + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + // 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 representsSameCA must treat as the same CA. + assertThatCode(() -> aiaOcspService.validateResponderCertificate( + responderHolder, crossIntermediateCertificate, Collections.singletonList(intermediateCertificate), NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderIsIssuedBySiblingIntermediate_thenValidationFails() throws Exception { + final X509CertificateHolder responderHolder = + new X509CertificateHolder(siblingResponderCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> aiaOcspService.validateResponderCertificate( + responderHolder, intermediateCertificate, + List.of(intermediateCertificate, siblingIntermediateCertificate), NOW)) + .withCauseInstanceOf(CertificateException.class); + } + + @Test + void whenResponderIsIssuedByRootInsteadOfSubjectIssuer_thenAiaValidationFails() throws Exception { + final X509CertificateHolder responderHolder = + new X509CertificateHolder(rootIssuedResponderCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> aiaOcspService.validateResponderCertificate( + responderHolder, intermediateCertificate, Collections.emptyList(), NOW)) + .withCauseInstanceOf(CertificateException.class); + } + + @Test + void whenRootIssuedResponderIsExplicitlyDesignatedForSubjectIssuer_thenValidationSucceeds() throws Exception { + final DesignatedOcspServiceConfiguration designatedConfiguration = + new DesignatedOcspServiceConfiguration( + URI.create(OCSP_URL), rootIssuedResponderCertificate, List.of(intermediateCertificate), true); + final OcspServiceProvider provider = + new OcspServiceProvider(designatedConfiguration, aiaOcspServiceConfiguration); + final OcspService service = provider.getService(subjectCertificate); + final X509CertificateHolder responderHolder = + new X509CertificateHolder(rootIssuedResponderCertificate.getEncoded()); + + assertThat(service).isInstanceOf(DesignatedOcspService.class); + assertThatCode(() -> service.validateResponderCertificate( + responderHolder, intermediateCertificate, Collections.emptyList(), NOW)) + .doesNotThrowAnyException(); + } + + private static KeyPair generateKeyPair() throws Exception { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate(String subjectCn, PublicKey subjectPublicKey, + String issuerCn, KeyPair issuerKeyPair, long serial, + boolean ca, boolean ocspSigning, String ocspUrl) throws Exception { + final JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + new X500Name("CN=" + issuerCn), BigInteger.valueOf(serial), NOT_BEFORE, NOT_AFTER, + new X500Name("CN=" + subjectCn), subjectPublicKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca)); + builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(issuerKeyPair.getPublic())); + if (ca) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + } + if (ocspSigning) { + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + } + if (ocspUrl != null) { + builder.addExtension(Extension.authorityInfoAccess, false, + new AuthorityInformationAccess(new AccessDescription(AccessDescription.id_ad_ocsp, + new GeneralName(GeneralName.uniformResourceIdentifier, ocspUrl)))); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeyPair.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private static X509CRL generateCrl(X500Name issuer, PrivateKey issuerPrivateKey) throws Exception { + final X509v2CRLBuilder builder = new X509v2CRLBuilder(issuer, NOT_BEFORE); + builder.setNextUpdate(NOT_AFTER); + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CRLConverter().getCRL(builder.build(signer)); + } +} diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java index 3956db5f..37a35082 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java @@ -22,15 +22,15 @@ package eu.webeid.security.validator.versionvalidators; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateLoader; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.CertificateDecodingException; +import eu.webeid.security.exceptions.CertificateExpiredException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.exceptions.CertificateNotYetValidException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.AuthTokenSignatureValidator; @@ -49,6 +49,8 @@ import java.security.cert.CertificateException; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.Set; @@ -56,6 +58,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -63,20 +66,9 @@ class AuthTokenV11CertificateTest extends AbstractTestWithValidator { - private static final String V11_AUTH_TOKEN = "{\"algorithm\":\"ES384\"," + - "\"unverifiedCertificate\":\"MIIEBDCCA2WgAwIBAgIQY5OGshxoPMFg+Wfc0gFEaTAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTIxMDcyMjEyNDMwOFoXDTI2MDcwOTIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQmwEKsJTjaMHSaZj19hb9EJaJlwbKc5VFzmlGMFSJVk4dDy+eUxa5KOA7tWXqzcmhh5SYdv+MxcaQKlKWLMa36pfgv20FpEDb03GCtLqjLTRZ7649PugAQ5EmAqIic29CjggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFPlp/ceABC52itoqppEmbf71TJz6MGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBjAAwgYgCQgDCAgybz0u3W+tGI+AX+PiI5CrE9ptEHO5eezR1Jo4j7iGaO0i39xTGUB+NSC7P6AQbyE/ywqJjA1a62jTLcS9GHAJCARxN4NO4eVdWU3zVohCXm8WN3DWA7XUcn9TZiLGQ29P4xfQZOXJi/z4PNRRsR4plvSNB3dfyBvZn31HhC7my8woi\"," + - "\"unverifiedSigningCertificates\":[{" + - "\"certificate\":\"X5C\"," + - "\"supportedSignatureAlgorithms\":[{\"cryptoAlgorithm\":\"RSA\",\"hashFunction\":\"SHA-256\",\"paddingScheme\":\"PKCS1.5\"}]" + - "}]," + - "\"appVersion\":\"https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0\"," + - "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," + - "\"format\":\"web-eid:1.1\"}"; - private static final String DIFFERENT_CERT = "MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E="; private MockedStatic mockedClock; - private static final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(WebEidAuthToken.class); private SubjectCertificateValidatorBatch scvb; private Set trustedCACertificateAnchors; private CertStore trustedCACertificateCertStore; @@ -116,13 +108,11 @@ void whenValidV11Token_thenValidationSucceeds() { @Test void whenV11SigningCertificateFieldIsMissing_thenValidationFails() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode node = (ObjectNode) mapper.readTree(V11_AUTH_TOKEN); - node.remove("unverifiedSigningCertificates"); - WebEidAuthToken token = OBJECT_READER.readValue(node.toString()); + WebEidAuthToken token = validator.parse(VALID_V11_AUTH_TOKEN); + token.setUnverifiedSigningCertificates(null); AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); assertThatThrownBy(() -> spyValidator.validate(token, VALID_CHALLENGE_NONCE)) .isInstanceOf(AuthTokenParseException.class) @@ -132,8 +122,8 @@ void whenV11SigningCertificateFieldIsMissing_thenValidationFails() throws Except @Test void whenV11SigningCertificateIsNotBase64_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class).getUnverifiedCertificate()); - doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any()); + X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any(), any()); WebEidAuthToken token = getWebEidAuthToken("This is not a certificate"); assertThatThrownBy(() -> spyValidator @@ -147,8 +137,8 @@ void whenV11SigningCertificateIsNotBase64_thenValidationFails() throws Exception @Test void whenV11SigningCertificateIsNotACertificate_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class).getUnverifiedCertificate()); - doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any()); + X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any(), any()); WebEidAuthToken token = getWebEidAuthToken("VGhpcyBpcyBub3QgYSBjZXJ0aWZpY2F0ZQ"); assertThatThrownBy(() -> spyValidator.validate(token, VALID_CHALLENGE_NONCE)) @@ -161,8 +151,8 @@ void whenV11SigningCertificateIsNotACertificate_thenValidationFails() throws Exc @Test void whenV11SigningCertificateSubjectDoesNotMatch_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class).getUnverifiedCertificate()); - doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any()); + X509Certificate mockSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(mockSubjectCert).when(spyValidator).validateV1(any(), any(), any()); WebEidAuthToken token = getWebEidAuthToken(DIFFERENT_CERT); assertThatThrownBy(() -> spyValidator.validate(token, VALID_CHALLENGE_NONCE)) @@ -173,9 +163,9 @@ void whenV11SigningCertificateSubjectDoesNotMatch_thenValidationFails() throws E @Test void whenV11SigningCertificateNotIssuedBySameAuthority_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - WebEidAuthToken parsedToken = OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class); + WebEidAuthToken parsedToken = validator.parse(VALID_V11_AUTH_TOKEN); X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(parsedToken.getUnverifiedCertificate()); - doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); X509Certificate mockSigningCert = mock(X509Certificate.class); when(mockSigningCert.getSubjectX500Principal()).thenReturn(realSubjectCert.getSubjectX500Principal()); @@ -202,9 +192,9 @@ void whenV11SigningCertificateNotIssuedBySameAuthority_thenValidationFails() thr @Test void whenV11SigningCertificateHasNoAuthorityKeyIdentifier_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - WebEidAuthToken parsedToken = OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class); + WebEidAuthToken parsedToken = validator.parse(VALID_V11_AUTH_TOKEN); X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(parsedToken.getUnverifiedCertificate()); - doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); X509Certificate mockSigningCert = mock(X509Certificate.class); when(mockSigningCert.getSubjectX500Principal()).thenReturn(realSubjectCert.getSubjectX500Principal()); @@ -225,9 +215,9 @@ void whenV11SigningCertificateHasNoAuthorityKeyIdentifier_thenValidationFails() @Test void whenV11SigningCertificateNotSuitableForSigning_thenValidationFails() throws Exception { AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); - WebEidAuthToken parsedToken = OBJECT_READER.readValue(V11_AUTH_TOKEN, WebEidAuthToken.class); + WebEidAuthToken parsedToken = validator.parse(VALID_V11_AUTH_TOKEN); X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(parsedToken.getUnverifiedCertificate()); - doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); X509Certificate signingCert = mock(X509Certificate.class); when(signingCert.getSubjectX500Principal()).thenReturn(realSubjectCert.getSubjectX500Principal()); @@ -248,6 +238,116 @@ void whenV11SigningCertificateNotSuitableForSigning_thenValidationFails() throws } } + @Test + void whenValidV11TokenWithUnverifiedIntermediateCertificates_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenUnverifiedSigningCertificatesAbsentButUnverifiedIntermediateCertificatesPresent_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.setUnverifiedSigningCertificates(null); + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenValidV11TokenWithSigningIntermediateCertificates_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.getUnverifiedSigningCertificates().get(0) + .setIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenUnverifiedIntermediateCertificatesEmpty_thenValidationFails() throws Exception { + validV11AuthToken.setUnverifiedIntermediateCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedIntermediateCertificates' must not be empty for format 'web-eid:1.1'"); + } + + @Test + void whenUnverifiedIntermediateCertificateContainsEmptyEntry_thenValidationFails() throws Exception { + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList("")); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedIntermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); + } + + @Test + void whenUnverifiedIntermediateCertificateIsNotBase64_thenValidationFails() throws Exception { + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList("This is not a certificate")); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(CertificateDecodingException.class); + } + + @Test + void whenV11SigningCertificateNotTrusted_thenValidationFails() throws Exception { + AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); + X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); + + try (MockedStatic mocked = mockStatic(CertificateValidator.class)) { + mocked.when(() -> CertificateValidator.validateIsSignedByTrustedCA(any(), any(), any(), anyList(), any())) + .thenThrow(new CertificateNotTrustedException(realSubjectCert, new RuntimeException("not trusted"))); + + assertThatThrownBy(() -> spyValidator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessageContaining("Signing certificate validation failed") + .hasCauseInstanceOf(CertificateNotTrustedException.class); + } + } + + @Test + void whenV11SigningCertificateExpired_thenValidationFails() throws Exception { + // Move the clock past the signing certificate's validity window so that the real + // CertificateValidator.validateIsSignedByTrustedCA() rejects it as expired. + mockDate("2099-01-01", mockedClock); + AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); + X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessageContaining("Signing certificate validation failed") + .hasCauseInstanceOf(CertificateExpiredException.class); + } + + @Test + void whenV11SigningCertificateNotYetValid_thenValidationFails() throws Exception { + // Move the clock before the signing certificate's validity window so that the real + // CertificateValidator.validateIsSignedByTrustedCA() rejects it as not yet valid. + mockDate("2000-01-01", mockedClock); + AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); + X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessageContaining("Signing certificate validation failed") + .hasCauseInstanceOf(CertificateNotYetValidException.class); + } + + private static String esteid2018CaCertificateInBase64() throws Exception { + X509Certificate caCertificate = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID2018.cer")[0]; + return Base64.getEncoder().encodeToString(caCertificate.getEncoded()); + } + private AuthTokenVersion11Validator spyAuthTokenVersion11Validator() { return Mockito.spy(new AuthTokenVersion11Validator( scvb, @@ -260,16 +360,10 @@ private AuthTokenVersion11Validator spyAuthTokenVersion11Validator() { )); } - private static WebEidAuthToken getWebEidAuthToken(String cert) throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode node = (ObjectNode) mapper.readTree(V11_AUTH_TOKEN); - - ArrayNode certs = (ArrayNode) node.get("unverifiedSigningCertificates"); - ObjectNode certNode = (ObjectNode) certs.get(0); - - certNode.put("certificate", cert); - - return OBJECT_READER.readValue(node.toString()); + private WebEidAuthToken getWebEidAuthToken(String cert) throws AuthTokenException { + WebEidAuthToken token = validator.parse(VALID_V11_AUTH_TOKEN); + token.getUnverifiedSigningCertificates().get(0).setCertificate(cert); + return token; } } diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java index 12005ede..aadfcef9 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java @@ -23,10 +23,11 @@ package eu.webeid.security.validator.versionvalidators; import eu.webeid.security.authtoken.SupportedSignatureAlgorithm; +import eu.webeid.security.authtoken.UnverifiedSigningCertificate; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateLoader; -import eu.webeid.security.authtoken.UnverifiedSigningCertificate; import eu.webeid.security.exceptions.AuthTokenParseException; +import eu.webeid.security.exceptions.JceException; import eu.webeid.security.validator.AuthTokenSignatureValidator; import eu.webeid.security.validator.AuthTokenValidationConfiguration; import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; @@ -42,6 +43,7 @@ import org.mockito.Mockito; import javax.security.auth.x500.X500Principal; +import java.security.InvalidAlgorithmParameterException; import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; @@ -49,6 +51,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; @@ -99,9 +102,10 @@ void whenUnverifiedSigningCertificatesMissing_thenValidationFails() throws Excep WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1.1"); when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); - doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) .isInstanceOf(AuthTokenParseException.class) @@ -113,13 +117,14 @@ void whenUnverifiedSigningCertificatesContainsNullEntry_thenValidationFails() th WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1.1"); when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(null)); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); - doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) .isInstanceOf(AuthTokenParseException.class) - .hasMessage("'unverifiedSigningCertificates' contains a null or empty entry for format 'web-eid:1.1'"); + .hasMessage("'unverifiedSigningCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); } @Test @@ -127,19 +132,21 @@ void whenUnverifiedSigningCertificateValueMissing_thenValidationFails() throws E WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1.1"); when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(new UnverifiedSigningCertificate())); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); - doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) .isInstanceOf(AuthTokenParseException.class) - .hasMessage("'unverifiedSigningCertificates' contains a null or empty entry for format 'web-eid:1.1'"); + .hasMessage("'unverifiedSigningCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); } @Test void whenSupportedSignatureAlgorithmsMissing_thenValidationFails() throws Exception { WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); UnverifiedSigningCertificate certificate = new UnverifiedSigningCertificate(); certificate.setCertificate("abc"); @@ -148,7 +155,7 @@ void whenSupportedSignatureAlgorithmsMissing_thenValidationFails() throws Except when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); - doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { mocked.when(() -> CertificateLoader.decodeCertificateFromBase64("abc")) @@ -164,6 +171,7 @@ void whenSupportedSignatureAlgorithmsMissing_thenValidationFails() throws Except void whenSigningCertificateChainValidationFails_thenValidationFails() throws Exception { WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); SupportedSignatureAlgorithm algorithm = new SupportedSignatureAlgorithm(); algorithm.setCryptoAlgorithm("RSA"); @@ -177,7 +185,7 @@ void whenSigningCertificateChainValidationFails_thenValidationFails() throws Exc when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); X500Principal subject = new X500Principal("CN=TEST"); - byte[] authorityKeyIdentifier = new byte[] { + byte[] authorityKeyIdentifier = new byte[]{ 0x04, 0x18, 0x30, 0x16, (byte) 0x80, 0x14, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 @@ -192,10 +200,10 @@ void whenSigningCertificateChainValidationFails_thenValidationFails() throws Exc when(signingCertificate.getSubjectX500Principal()).thenReturn(subject); when(signingCertificate.getExtensionValue(Extension.authorityKeyIdentifier.getId())) .thenReturn(authorityKeyIdentifier); - when(signingCertificate.getKeyUsage()).thenReturn(new boolean[] {false, true}); + when(signingCertificate.getKeyUsage()).thenReturn(new boolean[]{false, true}); AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); - doReturn(subjectCertificate).when(spyValidator).validateV1(any(), any()); + doReturn(subjectCertificate).when(spyValidator).validateV1(any(), any(), any()); try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { mocked.when(() -> CertificateLoader.decodeCertificateFromBase64("abc")) @@ -203,7 +211,104 @@ void whenSigningCertificateChainValidationFails_thenValidationFails() throws Exc assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) .isInstanceOf(AuthTokenParseException.class) - .hasMessage("Signing certificate chain validation failed"); + .hasMessage("Signing certificate validation failed"); + } + } + + @Test + void whenUnverifiedSigningCertificatesMissingButIntermediateCertificatesPresent_thenValidationSucceeds() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(Collections.singletonList("intermediate")); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); + + try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { + mocked.when(() -> CertificateLoader.decodeCertificatesFromBase64(Collections.singletonList("intermediate"))) + .thenReturn(Collections.emptyList()); + + assertThatCode(() -> spyValidator.validate(token, "nonce")) + .doesNotThrowAnyException(); + } + } + + @Test + void whenUnverifiedSigningCertificatesEmpty_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.emptyList()); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(Collections.singletonList("intermediate")); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); + + try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { + mocked.when(() -> CertificateLoader.decodeCertificatesFromBase64(Collections.singletonList("intermediate"))) + .thenReturn(Collections.emptyList()); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); } } + + @Test + void whenSigningCertificateIntermediateCertificatesEmpty_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); + + UnverifiedSigningCertificate certificate = new UnverifiedSigningCertificate(); + certificate.setCertificate("abc"); + certificate.setSupportedSignatureAlgorithms(Collections.singletonList(validSignatureAlgorithm())); + certificate.setIntermediateCertificates(Collections.emptyList()); + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); + + try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { + mocked.when(() -> CertificateLoader.decodeCertificateFromBase64("abc")) + .thenReturn(mock(X509Certificate.class)); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'intermediateCertificates' must not be empty for format 'web-eid:1.1'"); + } + } + + @Test + void whenSigningCertificateIntermediateCertificatesContainsEmptyEntry_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); + + UnverifiedSigningCertificate certificate = new UnverifiedSigningCertificate(); + certificate.setCertificate("abc"); + certificate.setSupportedSignatureAlgorithms(Collections.singletonList(validSignatureAlgorithm())); + certificate.setIntermediateCertificates(Collections.singletonList("")); + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any(), any()); + + try (MockedStatic mocked = mockStatic(CertificateLoader.class)) { + mocked.when(() -> CertificateLoader.decodeCertificateFromBase64("abc")) + .thenReturn(mock(X509Certificate.class)); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'intermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); + } + } + + private static SupportedSignatureAlgorithm validSignatureAlgorithm() { + SupportedSignatureAlgorithm algorithm = new SupportedSignatureAlgorithm(); + algorithm.setCryptoAlgorithm("RSA"); + algorithm.setHashFunction("SHA-256"); + algorithm.setPaddingScheme("PKCS1.5"); + return algorithm; + } } diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java index 271f5ed3..63949bba 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java @@ -22,8 +22,8 @@ package eu.webeid.security.validator.versionvalidators; -import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.authtoken.UnverifiedSigningCertificate; +import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.validator.AuthTokenSignatureValidator; import eu.webeid.security.validator.AuthTokenValidationConfiguration; @@ -78,6 +78,7 @@ void whenUnverifiedCertificateMissing_thenValidationFails() { WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1"); when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); when(token.getUnverifiedCertificate()).thenReturn(null); assertThatThrownBy(() -> validator.validate(token, "nonce")) @@ -95,4 +96,16 @@ void whenUnverifiedSigningCertificatesPresentForV1_thenValidationFails() { .isInstanceOf(AuthTokenParseException.class) .hasMessageContaining("'unverifiedSigningCertificates' field is not allowed for format 'web-eid:1'"); } + + @Test + void whenUnverifiedIntermediateCertificatesPresentForV1_thenValidationFails() { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(List.of("intermediate")); + + assertThatThrownBy(() -> validator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessageContaining("'unverifiedIntermediateCertificates' field is not allowed for format 'web-eid:1'"); + } }