From 0c03373e10d7fe362e0d29a6e32cf1316f5d7485 Mon Sep 17 00:00:00 2001 From: Nayyar Date: Sun, 28 Jun 2026 16:51:06 +0530 Subject: [PATCH 1/2] validate key attestation x5c chain against configured truststore Signed-off-by: Nayyar --- .../keybinding/AttestationValidatorUtil.java | 66 ++++++++++--- .../keybinding/JwtProofValidator.java | 2 +- .../AttestationValidatorUtilX5cTest.java | 99 +++++++++++++++++++ 3 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtilX5cTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java index 6161c70a3093..703c1b562a8b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java @@ -41,6 +41,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import javax.security.auth.x500.X500Principal; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; @@ -67,6 +68,7 @@ import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData; +import org.keycloak.truststore.TruststoreProvider; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.JsonProcessingException; @@ -355,7 +357,7 @@ private static SignatureVerifierContext verifierFromX5CChain( List x5cList, String alg, KeycloakSession keycloakSession) throws VCIssuerException, VerificationException { - JWK certJwk = resolveJwkFromValidatedX5c(x5cList, alg); + JWK certJwk = resolveJwkFromValidatedX5c(x5cList, alg, keycloakSession); return verifierFromResolvedJWK(certJwk, alg, keycloakSession); } @@ -363,7 +365,11 @@ private static SignatureVerifierContext verifierFromX5CChain( * Validates x5c certificate chain and converts leaf certificate key to JWK. * Can be reused by proof validators that accept x5c as proof key source. */ - static JWK resolveJwkFromValidatedX5c(List x5cList, String alg) throws VCIssuerException { + static JWK resolveJwkFromValidatedX5c(List x5cList, String alg, KeycloakSession session) throws VCIssuerException { + return resolveJwkFromValidatedX5c(x5cList, alg, getTrustAnchors(session)); + } + + static JWK resolveJwkFromValidatedX5c(List x5cList, String alg, Set trustAnchors) throws VCIssuerException { try { CertificateFactory cf = CertificateFactory.getInstance("X.509"); @@ -391,7 +397,7 @@ static JWK resolveJwkFromValidatedX5c(List x5cList, String alg) throws V // Validate certificate chain CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - PKIXParameters params = new PKIXParameters(getTrustAnchors()); + PKIXParameters params = new PKIXParameters(trustAnchors); params.setRevocationEnabled(false); validator.validate(certPath, params); @@ -400,28 +406,62 @@ static JWK resolveJwkFromValidatedX5c(List x5cList, String alg) throws V PublicKey publicKey = certChain.get(0).getPublicKey(); return convertPublicKeyToJWK(publicKey, alg, certChain); + } catch (VCIssuerException e) { + throw e; } catch (Exception e) { throw new VCIssuerException(ErrorType.INVALID_PROOF, "Failed to validate x5c certificate chain", e); } } - private static Set getTrustAnchors() throws Exception { - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (InputStream in = new FileInputStream(CACERTS_PATH)) { - trustStore.load(in, DEFAULT_TRUSTSTORE_PASSWORD); + /** + * Resolve the trust anchors used to validate a key attestation x5c chain. The chain must anchor in + * the realm's configured truststore, matching how Keycloak validates other X.509 chains (see + * CertificateValidator). Only when no truststore is configured does this fall back to the JVM + * default truststore, so an attestation cannot be satisfied by an arbitrary publicly-trusted CA + * when an operator has narrowed trust to the attestation issuers they accept. + */ + private static Set getTrustAnchors(KeycloakSession session) throws VCIssuerException { + Set anchors = new HashSet<>(); + + TruststoreProvider truststoreProvider = session == null ? null : session.getProvider(TruststoreProvider.class); + if (truststoreProvider != null && truststoreProvider.getTruststore() != null) { + addAnchors(anchors, truststoreProvider.getRootCertificates()); + addAnchors(anchors, truststoreProvider.getIntermediateCertificates()); + if (!anchors.isEmpty()) { + return anchors; + } } - Set anchors = new HashSet<>(); - Enumeration aliases = trustStore.aliases(); - while (aliases.hasMoreElements()) { - Certificate cert = trustStore.getCertificate(aliases.nextElement()); - if (cert instanceof X509Certificate) { - anchors.add(new TrustAnchor((X509Certificate) cert, null)); + try { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = new FileInputStream(CACERTS_PATH)) { + trustStore.load(in, DEFAULT_TRUSTSTORE_PASSWORD); } + + Enumeration aliases = trustStore.aliases(); + while (aliases.hasMoreElements()) { + Certificate cert = trustStore.getCertificate(aliases.nextElement()); + if (cert instanceof X509Certificate) { + anchors.add(new TrustAnchor((X509Certificate) cert, null)); + } + } + } catch (Exception e) { + throw new VCIssuerException(ErrorType.INVALID_PROOF, "Failed to load trust anchors for key attestation", e); } return anchors; } + private static void addAnchors(Set anchors, Map> certificates) { + if (certificates == null) { + return; + } + for (List certs : certificates.values()) { + for (X509Certificate cert : certs) { + anchors.add(new TrustAnchor(cert, null)); + } + } + } + private static SignatureVerifierContext verifierFromResolvedJWK( JWK jwk, String alg, diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java index c6ec26912016..0687efdb4e8d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java @@ -175,7 +175,7 @@ private JWK validateSingleJwtProof(VCIssuanceContext vcIssuanceContext, String j } } } else if (jwsHeader.getX5c() != null && !jwsHeader.getX5c().isEmpty()) { - jwk = AttestationValidatorUtil.resolveJwkFromValidatedX5c(jwsHeader.getX5c(), jwsHeader.getAlgorithm().name()); + jwk = AttestationValidatorUtil.resolveJwkFromValidatedX5c(jwsHeader.getX5c(), jwsHeader.getAlgorithm().name(), keycloakSession); } else { throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing binding key. JWT must contain either jwk, kid, or x5c in header."); } diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtilX5cTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtilX5cTest.java new file mode 100644 index 000000000000..f28482d693c7 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtilX5cTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.keybinding; + +import java.security.KeyPair; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.Set; + +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; +import org.keycloak.common.util.CertificateUtils; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that key attestation x5c chains are only accepted when they anchor in the supplied trust + * anchors (i.e. the configured truststore), and not for arbitrary or self-signed certificates. + */ +public class AttestationValidatorUtilX5cTest { + + @BeforeAll + public static void beforeAll() { + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + } + + @Test + public void leafChainingToTrustedAnchorIsAccepted() throws Exception { + KeyPair caKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate caCert = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "CN=Test Attestation CA"); + + KeyPair leafKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate leafCert = CertificateUtils.generateV3Certificate(leafKeyPair, caKeyPair.getPrivate(), caCert, "CN=Attestation Leaf"); + + List x5c = List.of(Base64.getEncoder().encodeToString(leafCert.getEncoded())); + + JWK jwk = assertDoesNotThrow(() -> AttestationValidatorUtil.resolveJwkFromValidatedX5c( + x5c, "RS256", Set.of(new TrustAnchor(caCert, null)))); + assertNotNull(jwk); + assertEquals("RSA", jwk.getKeyType()); + } + + @Test + public void leafNotChainingToTrustedAnchorIsRejected() throws Exception { + KeyPair caKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate caCert = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "CN=Test Attestation CA"); + + KeyPair leafKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate leafCert = CertificateUtils.generateV3Certificate(leafKeyPair, caKeyPair.getPrivate(), caCert, "CN=Attestation Leaf"); + + List x5c = List.of(Base64.getEncoder().encodeToString(leafCert.getEncoded())); + + // A certificate signed by a CA that is not among the trust anchors must be rejected, even though + // it is a perfectly valid certificate. This is what stops an arbitrary publicly-trusted CA from + // satisfying a key attestation. + KeyPair otherCaKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate otherCaCert = CertificateUtils.generateV1SelfSignedCertificate(otherCaKeyPair, "CN=Untrusted CA"); + + assertThrows(VCIssuerException.class, () -> AttestationValidatorUtil.resolveJwkFromValidatedX5c( + x5c, "RS256", Set.of(new TrustAnchor(otherCaCert, null)))); + } + + @Test + public void selfSignedLeafIsRejected() throws Exception { + KeyPair caKeyPair = KeyUtils.generateRsaKeyPair(2048); + X509Certificate selfSigned = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "CN=Self Signed"); + + List x5c = List.of(Base64.getEncoder().encodeToString(selfSigned.getEncoded())); + + assertThrows(VCIssuerException.class, () -> AttestationValidatorUtil.resolveJwkFromValidatedX5c( + x5c, "RS256", Set.of(new TrustAnchor(selfSigned, null)))); + } +} From cdcfb789940a14bcef76ac64577b93b6326d6da9 Mon Sep 17 00:00:00 2001 From: Nayyar Date: Mon, 29 Jun 2026 13:45:43 +0530 Subject: [PATCH 2/2] reject empty configured truststore for attestation x5c instead of falling back Signed-off-by: Nayyar --- .../issuance/keybinding/AttestationValidatorUtil.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java index 703c1b562a8b..3816efdd1e1d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java @@ -427,9 +427,14 @@ private static Set getTrustAnchors(KeycloakSession session) throws if (truststoreProvider != null && truststoreProvider.getTruststore() != null) { addAnchors(anchors, truststoreProvider.getRootCertificates()); addAnchors(anchors, truststoreProvider.getIntermediateCertificates()); - if (!anchors.isEmpty()) { - return anchors; + // A truststore is configured, so it is the authority for attestation trust. Do not fall back to + // the JVM default here: an empty configured truststore must reject the chain rather than silently + // re-broaden trust to every publicly-trusted CA. + if (anchors.isEmpty()) { + throw new VCIssuerException(ErrorType.INVALID_PROOF, + "Configured truststore contains no certificates to anchor the key attestation x5c chain"); } + return anchors; } try {