Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -355,15 +357,19 @@ private static SignatureVerifierContext verifierFromX5CChain(
List<String> x5cList,
String alg,
KeycloakSession keycloakSession) throws VCIssuerException, VerificationException {
JWK certJwk = resolveJwkFromValidatedX5c(x5cList, alg);
JWK certJwk = resolveJwkFromValidatedX5c(x5cList, alg, keycloakSession);
return verifierFromResolvedJWK(certJwk, alg, keycloakSession);
}

/**
* 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<String> x5cList, String alg) throws VCIssuerException {
static JWK resolveJwkFromValidatedX5c(List<String> x5cList, String alg, KeycloakSession session) throws VCIssuerException {
return resolveJwkFromValidatedX5c(x5cList, alg, getTrustAnchors(session));
}

static JWK resolveJwkFromValidatedX5c(List<String> x5cList, String alg, Set<TrustAnchor> trustAnchors) throws VCIssuerException {

try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Expand Down Expand Up @@ -391,7 +397,7 @@ static JWK resolveJwkFromValidatedX5c(List<String> 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);
Expand All @@ -400,28 +406,67 @@ static JWK resolveJwkFromValidatedX5c(List<String> 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<TrustAnchor> 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<TrustAnchor> getTrustAnchors(KeycloakSession session) throws VCIssuerException {
Set<TrustAnchor> 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());
// 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;
}

Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> 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<String> 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<TrustAnchor> anchors, Map<X500Principal, List<X509Certificate>> certificates) {
if (certificates == null) {
return;
}
for (List<X509Certificate> certs : certificates.values()) {
for (X509Certificate cert : certs) {
anchors.add(new TrustAnchor(cert, null));
}
}
}

private static SignatureVerifierContext verifierFromResolvedJWK(
JWK jwk,
String alg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> x5c = List.of(Base64.getEncoder().encodeToString(selfSigned.getEncoded()));

assertThrows(VCIssuerException.class, () -> AttestationValidatorUtil.resolveJwkFromValidatedX5c(
x5c, "RS256", Set.of(new TrustAnchor(selfSigned, null))));
}
}
Loading