Skip to content
Merged
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
@@ -0,0 +1,158 @@
/*
* Copyright 2026 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.crypto;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.PublicKey;
import java.security.cert.CertificateFactory;
import java.security.cert.CollectionCertStoreParameters;
import java.security.cert.PKIXBuilderParameters;
import java.security.cert.TrustAnchor;
import java.security.cert.X509CertSelector;
import java.security.cert.X509Certificate;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.keycloak.common.VerificationException;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;

/**
* Validates x5c certificate chains of end entity signing certificates against a set of trust
* anchors and an optional extended key usage requirement.
*/
public final class X509CertificateChainValidator {

private static final int MAX_CERTIFICATE_CHAIN_LENGTH = 10;

private X509CertificateChainValidator() {
}

/**
* Validates the x5c certificate chain against the given trust anchors and returns the leaf key
* as JWK. The leaf must be a currently valid end entity certificate usable for digital
* signatures. When {@code requiredExtendedKeyUsages} is not empty, the leaf must additionally
* contain at least one of the given extended key usage OIDs.
*/
public static JWK validate(List<String> x5c, String algorithm, Collection<X509Certificate> trustAnchors,
List<String> requiredExtendedKeyUsages) throws VerificationException {
try {
List<X509Certificate> certificateChain = decodeCertificateChain(x5c);
X509Certificate leaf = certificateChain.get(0);

rejectSelfSignedLeaf(leaf);
validateLeafPurpose(leaf, requiredExtendedKeyUsages);

Set<TrustAnchor> anchors = new HashSet<>();
trustAnchors.forEach(certificate -> anchors.add(new TrustAnchor(certificate, null)));

X509CertSelector selector = new X509CertSelector();
selector.setCertificate(leaf);
PKIXBuilderParameters parameters = new PKIXBuilderParameters(anchors, selector);
parameters.addCertStore(CryptoIntegration.getProvider().getCertStore(
new CollectionCertStoreParameters(certificateChain)));
// PKIX enables its provider specific revocation mechanism by default. Certificate
// revocation is not supported yet, so disable it explicitly until a configurable
// mechanism is available.
parameters.setRevocationEnabled(false);

CryptoIntegration.getProvider().getCertPathBuilder().build(parameters);
return toJwk(leaf, algorithm, certificateChain);
} catch (VerificationException e) {
throw e;
} catch (Exception e) {
throw new VerificationException(
"Failed to validate the x5c certificate chain against the configured trust anchors", e);
}
}

public static List<X509Certificate> decodeCertificateChain(List<String> x5c) throws VerificationException {
if (x5c == null || x5c.isEmpty()) {
throw new VerificationException("The x5c certificate chain is empty");
}
if (x5c.size() > MAX_CERTIFICATE_CHAIN_LENGTH) {
throw new VerificationException("The x5c certificate chain is too long");
}

try {
CertificateFactory certificateFactory = CryptoIntegration.getProvider().getX509CertFactory();
List<X509Certificate> certificates = new ArrayList<>(x5c.size());
for (String encodedCertificate : x5c) {
byte[] der = Base64.getMimeDecoder().decode(encodedCertificate);
try (InputStream input = new ByteArrayInputStream(der)) {
certificates.add((X509Certificate) certificateFactory.generateCertificate(input));
}
}
return Collections.unmodifiableList(certificates);
} catch (Exception e) {
throw new VerificationException("Failed to decode x5c certificate chain", e);
}
}

public static JWK toJwk(X509Certificate leaf, String algorithm, List<X509Certificate> certificateChain)
throws VerificationException {
PublicKey key = leaf.getPublicKey();
if (key instanceof RSAPublicKey) {
return JWKBuilder.create().algorithm(algorithm).rsa((RSAPublicKey) key, certificateChain);
}
if (key instanceof ECPublicKey) {
return JWKBuilder.create().algorithm(algorithm).ec((ECPublicKey) key, certificateChain, null);
}
throw new VerificationException("Unsupported public key type in certificate: " + key.getClass().getName());
}

private static void rejectSelfSignedLeaf(X509Certificate leaf) throws Exception {
if (!leaf.getSubjectX500Principal().equals(leaf.getIssuerX500Principal())) {
return;
}
leaf.verify(leaf.getPublicKey());
throw new VerificationException("Self signed certificates are not accepted as chain leaf");
}

private static void validateLeafPurpose(X509Certificate leaf, List<String> requiredExtendedKeyUsages)
throws Exception {
leaf.checkValidity();
if (leaf.getBasicConstraints() >= 0) {
throw new VerificationException("The chain leaf must be an end entity certificate");
}

boolean[] keyUsage = leaf.getKeyUsage();
if (keyUsage != null && (keyUsage.length == 0 || !keyUsage[0])) {
throw new VerificationException("The chain leaf certificate is not valid for digital signatures");
}

if (requiredExtendedKeyUsages.isEmpty()) {
return;
}
List<String> certificateExtendedKeyUsages = leaf.getExtendedKeyUsage();
if (certificateExtendedKeyUsages == null
|| requiredExtendedKeyUsages.stream().noneMatch(certificateExtendedKeyUsages::contains)) {
throw new VerificationException(
"The chain leaf certificate does not contain any of the required extended key usages");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Trusted attester keys are configured per client because different wallets can re

. Create or configure a trust-material identity provider, such as **Default Trust**, that exposes the trusted attester public keys or X.509 trust anchors.
** For `kid`-based attestations, configure a JWKS URL or validating public key.
** For `x5c`-based attestations, enable **Use X.509 attestation trust**, provide a PEM bundle containing only self-signed device-vendor attestation CA roots, and configure **Allowed attestation extended key usages** with at least one Extended Key Usage (EKU) OID. This setting is required. The attestation end-entity certificate must contain an EKU extension with at least one OID that exactly matches a configured value; otherwise, validation fails.
** For `x5c`-based attestations, enable **Use X.509 attestation trust**, provide a PEM bundle containing only self-signed device-vendor attestation CA roots, and configure **Required extended key usages** with the accepted Extended Key Usage (EKU) OIDs. When at least one OID is configured, the attestation end-entity certificate must contain an EKU extension with at least one OID that exactly matches a configured value; otherwise, validation fails. When the list is empty, no EKU restriction is applied and the trust anchors alone decide trust.
Comment thread
rmartinc marked this conversation as resolved.
Resolved
. Go to *Clients* and select the wallet client that requests credentials.
. Open the *Advanced* tab.
. In the *OpenID Verifiable Credentials* section, enable **Enable OID4VCI**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2022,8 +2022,8 @@ useX509AttestationTrust=Use X.509 attestation trust
useX509AttestationTrustHelp=Validate key-attestation certificate chains against dedicated trusted attestation roots instead of resolving a JWK.
trustedAttestationCertificates=Trusted attestation certificates
trustedAttestationCertificatesHelp=PEM bundle containing only self-signed device-vendor attestation CA root certificates. Do not use the general TLS trust store.
attestationExtendedKeyUsages=Allowed attestation extended key usages
attestationExtendedKeyUsagesHelp=Comma-separated object identifiers (OIDs) accepted in the leaf certificate's Extended Key Usage extension.
requiredExtendedKeyUsages=Required extended key usages
requiredExtendedKeyUsagesHelp=Comma-separated object identifiers (OIDs). The leaf certificate's Extended Key Usage extension must contain at least one of them. When empty, no extended key usage restriction is applied.
wantAssertionsSigned=Want Assertions signed
roleSaveSuccess=The role has been saved
scopeParameter=Scope parameter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function DefaultTrustSettings() {
const options = { shouldDirty: true };

setValue("config.trustedCertificates", "", options);
setValue("config.attestationExtendedKeyUsages", "", options);
setValue("config.requiredExtendedKeyUsages", "", options);
};

return (
Expand Down Expand Up @@ -57,10 +57,9 @@ export default function DefaultTrustSettings() {
rules={{ required: t("required") }}
/>
<TextControl
name="config.attestationExtendedKeyUsages"
label={t("attestationExtendedKeyUsages")}
labelIcon={t("attestationExtendedKeyUsagesHelp")}
rules={{ required: t("required") }}
name="config.requiredExtendedKeyUsages"
label={t("requiredExtendedKeyUsages")}
labelIcon={t("requiredExtendedKeyUsagesHelp")}
Comment on lines +60 to +62
/>
</>
) : (
Expand Down
31 changes: 30 additions & 1 deletion js/apps/admin-ui/src/identity-providers/add/Oid4VpSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { IdentityProviderType } from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { DynamicComponents } from "../../components/dynamic/DynamicComponents";
import { IdentityProviderSelect } from "../../components/identity-provider/IdentityProviderSelect";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import type { IdentityProviderParams } from "../routes/IdentityProvider";

const PROVIDER_ID = "oid4vp";
const TRUST_MATERIAL_IDPS = "trustMaterialIdps";

export default function Oid4VpSettings() {
const { t } = useTranslation();
Expand All @@ -21,6 +24,13 @@ export default function Oid4VpSettings() {
[serverInfo],
);

const trustMaterialIdps = properties.find(
({ name }) => name === TRUST_MATERIAL_IDPS,
);
const trustMaterialIndex = trustMaterialIdps
? properties.indexOf(trustMaterialIdps)
: properties.length;

return (
<>
<TextControl
Expand All @@ -33,7 +43,26 @@ export default function Oid4VpSettings() {
}}
/>
<TextControl name="displayName" label={t("displayName")} />
<DynamicComponents stringify properties={properties} />
<DynamicComponents
stringify
properties={properties.slice(0, trustMaterialIndex)}
/>
{trustMaterialIdps && (
<IdentityProviderSelect
name={`config.${TRUST_MATERIAL_IDPS}`}
label={trustMaterialIdps.label}
helpText={trustMaterialIdps.helpText}
convertToName={(name) => name}
identityProviderType={IdentityProviderType.TRUST_MATERIAL}
realmOnly
stringify
stringifySeparator=","
/>
)}
<DynamicComponents
stringify
properties={properties.slice(trustMaterialIndex + 1)}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ test.describe.serial("Default Trust identity provider test", () => {
await expect(page.getByTestId("config.useJwksUrl")).toBeHidden();
await expect(page.getByTestId("config.trustedCertificates")).toBeVisible();
await expect(
page.getByTestId("config.attestationExtendedKeyUsages"),
page.getByTestId("config.requiredExtendedKeyUsages"),
).toBeVisible();
});

Expand All @@ -79,9 +79,7 @@ test.describe.serial("Default Trust identity provider test", () => {
await page
.getByTestId("config.trustedCertificates")
.fill("stale certificate");
await page
.getByTestId("config.attestationExtendedKeyUsages")
.fill("1.2.3.4");
await page.getByTestId("config.requiredExtendedKeyUsages").fill("1.2.3.4");
await page.getByTestId("config.useX509").click({ force: true });
await clickSaveButton(page);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@

package org.keycloak.broker.provider;

import java.util.List;
import java.util.stream.Stream;

import org.keycloak.common.VerificationException;
import org.keycloak.crypto.X509CertificateChainValidator;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.models.IdentityProviderModel;

Expand All @@ -37,4 +40,49 @@ default Stream<X509TrustMaterial> resolveX509Trust(TrustMaterialRequest request)
return Stream.empty();
}

/**
* Validates the x5c certificate chain of a presented artifact against this provider's X.509
* trust material and returns the chain leaf key. Exposing X.509 trust anchors mandates chain
* validation: a missing or unverifiable chain fails.
*
* @return the leaf key of the validated chain, or null when this provider exposes no X.509
* trust material
*/
default JWK validateX509Chain(TrustMaterialRequest request, List<String> x5c, String algorithm)
throws VerificationException {
List<X509TrustMaterial> trustMaterials = resolveX509Trust(request).toList();
if (trustMaterials.isEmpty()) {
return null;
}
if (x5c == null || x5c.isEmpty()) {
throw new VerificationException(
"The configured X.509 trust anchors mandate certificate chain validation, but no x5c "
+ "certificate chain was presented");
}
return validateX509Chain(trustMaterials, x5c, algorithm);
}

/**
* Validates the x5c certificate chain against the given trust materials and returns the chain
* leaf key. The materials are tried in order and the first successful validation wins.
*
* @return the leaf key of the validated chain, or null if no trust material was given
*/
static JWK validateX509Chain(List<X509TrustMaterial> trustMaterials, List<String> x5c, String algorithm)
throws VerificationException {
VerificationException lastFailure = null;
for (X509TrustMaterial trustMaterial : trustMaterials) {
try {
return X509CertificateChainValidator.validate(x5c, algorithm,
trustMaterial.trustAnchors(), trustMaterial.requiredExtendedKeyUsages());
} catch (VerificationException e) {
lastFailure = e;
}
}
if (lastFailure != null) {
throw lastFailure;
}
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.keycloak.OID4VCConstants;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.JwkParsingUtils;
Expand All @@ -33,13 +34,15 @@
import com.fasterxml.jackson.databind.JsonNode;

/**
* Bridges a {@link TrustMaterialIdentityProvider} to a {@link TrustedSdJwtIssuerResolver}: it reads
* the issuer key hints (kid, alg, iss) from the credential's issuer signed JWT and looks up the
* matching trusted keys.
* Bridges a {@link TrustMaterialIdentityProvider} to a {@link TrustedSdJwtIssuerResolver}. When the
* provider exposes X.509 trust anchors, chain validation is mandated as required by HAIP 6.1.1: the
* x5c header of the credential's issuer signed JWT must validate against the anchors and the chain
* leaf key verifies the signature, a credential without an x5c header is rejected. Only providers
* without X.509 trust anchors fall back to looking up the trusted keys by the issuer key hints
* (kid, alg, iss).
*
* TODO: This needs to be changed to either use the x5c-Header and do a Chain-Validation against
* a pre-configured cert / ETSI trust list (enforced in HAIP) OR look up the
* JWKS dynamically from .well-known/jwt-vc-issuer endpoint of the issuer in the credential
* TODO: Support looking up the JWKS dynamically from the .well-known/jwt-vc-issuer endpoint of the
* issuer in the credential.
*/
public class TrustMaterialSdJwtIssuerResolver implements TrustedSdJwtIssuerResolver {

Expand All @@ -58,6 +61,12 @@ public TrustedSdJwtIssuer resolve(IssuerSignedJWT issuerSignedJwt) throws Verifi
.algorithm(header.getRawAlgorithm())
.issuer(issuer != null && issuer.isTextual() ? issuer.textValue() : null)
.build();

JWK leafKey = trustMaterial.validateX509Chain(request, header.getX5c(), header.getRawAlgorithm());
if (leafKey != null) {
return new StaticTrustedSdJwtIssuer(List.of(JwkParsingUtils.convertJwkToVerifierContext(leafKey)));
}

List<SignatureVerifierContext> verifiers = trustMaterial.resolveKeys(request)
.map(JwkParsingUtils::convertJwkToVerifierContext)
.collect(Collectors.toList());
Expand Down
Loading
Loading