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
50 changes: 46 additions & 4 deletions docs/documentation/server_admin/topics/oid4vci/proofs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,29 @@ This section documents the proof types supported by OpenID for Verifiable Creden

When a credential requires cryptographic holder binding, the client must provide a proof of an appropriate type. The issuer validates the proof and extracts the binding key, which is then included in the issued credential.

==== Configuring Trusted Attester Keys
==== Configuring Trusted Attesters

{project_name} supports JWT proofs that can optionally include key attestations, and the `attestation` proof type. To validate these attestations, configure the trusted attester keys through trust-material identity providers and link those identity providers to the OID4VCI client.

Trusted attester keys are configured per client because different wallets can rely on different attestation authorities. The former realm-level options **Trusted Key IDs** and **Trusted Keys (JSON)** are no longer used for OID4VCI proof validation.

===== Admin Console

. Create or configure a trust-material identity provider, such as **Default Trust**, that exposes the trusted attester public keys. The identity provider must be configured with either a JWKS URL or a validating public key.
. 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.
. Go to *Clients* and select the wallet client that requests credentials.
. Open the *Advanced* tab.
. In the *OpenID Verifiable Credentials* section, enable **Enable OID4VCI**.
. In **OID4VCI Attester Trust Identity Providers**, select the trust-material identity providers that contain the trusted attester public keys.
. Save the client.

OID4VCI does not define a universal EKU OID for key attestation. Use the OID specified by the device vendor or the applicable attestation trust framework. For example, a private test PKI could place `1.3.6.1.4.1.32473.1.1` in both the certificate and the configuration to demonstrate exact matching. The `32473` private enterprise number is reserved for documentation and must not be used in production.

WARNING: The built-in OIDC identity provider exposes only JWK trust material and cannot provide X.509 trust anchors for `x5c`-based attestations. Use **Default Trust**, or a custom trust-material identity provider that implements X.509 trust resolution, for `x5c`-based attestations.

WARNING: Certificate revocation checking is not currently supported for `x5c` key-attestation certificate chains. Before trusting an attestation CA, ensure that revoked attestation certificates are rejected by a mandatory revocation mechanism outside {project_name}.

The **OID4VCI Attester Trust Identity Providers** option is only shown when **Enable OID4VCI** is enabled for the client. It accepts only trust-material identity providers from the current realm. If no trust-material identity provider is configured, the dropdown has no values to select.

===== Client Attribute
Expand All @@ -51,7 +59,7 @@ The **OID4VCI Attester Trust Identity Providers** option is only shown when **En

| `oid4vci.attester_trust_idps`
| none
| Comma-separated aliases of trust-material identity providers containing trusted attester public keys for OID4VCI key attestation validation.
| Comma-separated aliases of trust-material identity providers containing trusted attester public keys or X.509 attestation roots for OID4VCI key attestation validation.

|===

Expand All @@ -72,7 +80,41 @@ curl -X PUT "https://localhost:8443/admin/realms/{realm}/clients/{client-uuid}"
}'
----

For a JWT proof with a `key_attestation` JOSE header, or for an `attestation` proof, {project_name} resolves the attester signing key from the configured identity providers. Key resolution can use the attestation JWT header and payload, including the `kid`, `alg`, and `iss` values. If no configured identity provider exposes a matching trusted key, proof validation fails.
For a JWT proof with a `key_attestation` JOSE header, or for an `attestation` proof, {project_name} resolves the attester signing key from the configured identity providers. Key resolution can use the attestation JWT header and payload, including the `kid`, `alg`, and `iss` values. If no configured identity provider trusts the key or certificate chain, proof validation fails.

===== X.509 Certificate-Chain Validation

For an `x5c`-based key attestation, the certificates presented in the attestation JWT and the trust material configured in the identity provider have different roles:

[source,text]
----
Attestation JWT JOSE header Default Trust identity provider
--------------------------- -------------------------------
x5c[0]: attestation leaf certificate Trusted certificates:
- signs the attestation JWT device-vendor root CA
- must be an end-entity certificate (self-signed trust anchor)
- contains an allowed EKU

x5c[1..n]: intermediate CA certificates Allowed attestation extended
needed to build the path key usages:
(if any) device-vendor attestation EKU

x5c[last]: device-vendor root CA The same root is configured
(optional) as the trust anchor

Certificate path built by Keycloak
attestation leaf -> intermediate CA(s) -> configured root CA
----

The first `x5c` entry must be the certificate containing the public key that signed the attestation JWT. Each following entry must be an issuer of the previous certificate. The entries are base64-encoded DER certificates, not PEM values or base64url-encoded values.

Include any intermediate CA certificates required to connect the leaf to the configured root. The root certificate can be the final `x5c` entry, but it is not required because the same root is already configured as a trust anchor. For example, if the configured root directly issued the leaf, `x5c` can contain only the leaf certificate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When checking this, I am thinking about this corner-case scenario:

  • default-trust identity provider has configured certificate with subject=my-ca, issuer=my-ca, which is self-signed by ca-keypair1
  • The proof has x5c element with something like this (single leaf certificate, root certificate is omitted):
    • cert[0]: subject=my-leaf, issuer=my-ca, which is signed by ca-keypair2

In other words, the issuer from x5c is same value my-ca as the trusted CA certificate from identity provider configuration. However the x5c is signed by incorrect keypair ca-keypair2 (not by the ca-keypair1 from IDP config).

Just thinking about possible attack, when attacker knows the name of configured CA, however attacker creates his own CA certificate of same name, but signed by his own keypair (ca-keypair2), which he would use to sign his own root certificate and leaf certificate attached.

Do you think that such scenario would be rejected? Might be good to have automated test for this though?


For each selected X.509-capable trust-material identity provider, {project_name} independently builds a certificate path from the leaf to a root configured in that provider. The leaf must be an end-entity certificate, and its Extended Key Usage (EKU) extension must contain at least one OID allowed by the same provider. Trust roots and EKU policies from different providers are never combined.

A root included in `x5c` does not become trusted merely by being present. It must match a root configured in the identity provider. The JVM system truststore is never used as attestation trust.

Do not place public Web PKI roots or the general {project_name} TLS truststore in an attestation trust provider. Each provider should represent one device-vendor trust domain so its roots and certificate policy cannot be combined with another provider accidentally.

==== Creating and Sending JWT Proofs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2018,6 +2018,12 @@ requirement=Requirement
any=Any
minute=Minute
useJwksUrl=Use JWKS URL
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.
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
@@ -1,12 +1,29 @@
import { useTranslation } from "react-i18next";
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { TextAreaControl, TextControl } from "@keycloak/keycloak-ui-shared";
import { JwksSettings } from "./JwksSettings";
import { useParams } from "react-router-dom";
import type { IdentityProviderParams } from "../routes/IdentityProvider";
import { DefaultSwitchControl } from "../../components/SwitchControl";
import { useFormContext, useWatch } from "react-hook-form";
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";

export default function DefaultTrustSettings() {
const { t } = useTranslation();
const { tab } = useParams<IdentityProviderParams>();
const { control, setValue } =
useFormContext<IdentityProviderRepresentation>();
const useX509 = useWatch({
control,
name: "config.useX509",
defaultValue: "false",
});

const clearX509Settings = () => {
const options = { shouldDirty: true };

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

return (
<>
Expand All @@ -19,7 +36,36 @@ export default function DefaultTrustSettings() {
required: t("required"),
}}
/>
<JwksSettings />
<DefaultSwitchControl
name="config.useX509"
label={t("useX509AttestationTrust")}
labelIcon={t("useX509AttestationTrustHelp")}
defaultValue="false"
stringify
onChange={(_, checked) => {
if (!checked) {
clearX509Settings();
}
}}
/>
{useX509 === "true" ? (
Comment thread
forkimenjeckayang marked this conversation as resolved.
<>
<TextAreaControl
name="config.trustedCertificates"
label={t("trustedAttestationCertificates")}
labelIcon={t("trustedAttestationCertificatesHelp")}
rules={{ required: t("required") }}
/>
<TextControl
name="config.attestationExtendedKeyUsages"
label={t("attestationExtendedKeyUsages")}
labelIcon={t("attestationExtendedKeyUsagesHelp")}
rules={{ required: t("required") }}
/>
</>
) : (
<JwksSettings />
)}
</>
);
}
20 changes: 20 additions & 0 deletions js/apps/admin-ui/test/identity-providers/default-trust.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test.describe.serial("Default Trust identity provider test", () => {
await page.goto(addDefaultTrustProviderUrl);

await expect(page.getByTestId("alias")).toHaveValue(alias);
await expect(page.getByTestId("config.useX509")).not.toBeChecked();
await expect(page.getByTestId("config.useJwksUrl")).toBeChecked();
await expect(page.getByTestId("config.jwksUrl")).toBeVisible();

Expand All @@ -52,6 +53,13 @@ test.describe.serial("Default Trust identity provider test", () => {
page.getByTestId("config.publicKeySignatureVerifierKeyId"),
).toBeVisible();
await expect(page.getByTestId("import-certificate-button")).toBeVisible();

await page.getByTestId("config.useX509").click({ force: true });
await expect(page.getByTestId("config.useJwksUrl")).toBeHidden();
await expect(page.getByTestId("config.trustedCertificates")).toBeVisible();
await expect(
page.getByTestId("config.attestationExtendedKeyUsages"),
).toBeVisible();
});

test("should create and edit a Default Trust provider", async ({ page }) => {
Expand All @@ -67,6 +75,18 @@ test.describe.serial("Default Trust identity provider test", () => {
await expect(page.getByTestId("config.clientSecret")).toBeHidden();
await expect(page.getByTestId("mappers-tab")).toBeHidden();

await page.getByTestId("config.useX509").click({ force: true });
await page
.getByTestId("config.trustedCertificates")
.fill("stale certificate");
await page
.getByTestId("config.attestationExtendedKeyUsages")
.fill("1.2.3.4");
await page.getByTestId("config.useX509").click({ force: true });
await clickSaveButton(page);

await assertNotificationMessage(page, "Provider successfully updated");

await page.getByTestId("config.useJwksUrl").click({ force: true });
await page.getByTestId("config.publicKeySignatureVerifier").fill(jwks);
await clickSaveButton(page);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,11 @@ public interface TrustMaterialIdentityProvider<C extends IdentityProviderModel>

Stream<JWK> resolveKeys(TrustMaterialRequest request);

/**
* Resolves X.509 trust anchors and policy. Providers that only expose JWKs do not need to implement this method.
*/
default Stream<X509TrustMaterial> resolveX509Trust(TrustMaterialRequest request) {
return Stream.empty();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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.broker.provider;

import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Set;

/**
* X.509 trust material and validation policy exposed by a trust-material identity provider.
*
* @param trustAnchors self-signed CA roots for one trust domain
* @param allowedExtendedKeyUsages extended-key-usage OIDs accepted for end-entity certificates
*/
public record X509TrustMaterial(Set<X509Certificate> trustAnchors,
List<String> allowedExtendedKeyUsages) {

public X509TrustMaterial {
trustAnchors = Set.copyOf(trustAnchors);
allowedExtendedKeyUsages = List.copyOf(allowedExtendedKeyUsages);
if (trustAnchors.isEmpty()) {
throw new IllegalArgumentException("At least one X.509 trust anchor is required");
}
if (allowedExtendedKeyUsages.isEmpty()) {
throw new IllegalArgumentException("At least one attestation extended key usage is required");
}
trustAnchors.forEach(X509TrustMaterial::validateTrustAnchor);
}

private static void validateTrustAnchor(X509Certificate certificate) {
if (certificate.getBasicConstraints() < 0) {
throw new IllegalArgumentException("X.509 trust anchors must be CA certificates");
}

boolean[] keyUsage = certificate.getKeyUsage();
if (keyUsage != null && (keyUsage.length <= 5 || !keyUsage[5])) {
throw new IllegalArgumentException("X.509 trust anchors must be valid for certificate signing");
}

if (!certificate.getSubjectX500Principal().equals(certificate.getIssuerX500Principal())) {
throw new IllegalArgumentException("X.509 trust anchors must be self-issued root certificates");
}

try {
certificate.verify(certificate.getPublicKey());
} catch (Exception e) {
throw new IllegalArgumentException("X.509 trust anchors must be self-signed root certificates", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ public Optional<JWK> resolveKey(KeycloakSession session, String aliases, TrustMa
return resolveKeys(session, aliases, request).findFirst();
}

public Stream<X509TrustMaterial> resolveX509Trust(KeycloakSession session, String aliases,
TrustMaterialRequest request) {
if (Strings.isEmpty(aliases)) {
return Stream.empty();
}

return splitAliases(aliases).stream()
.map(alias -> resolveProvider(session, alias))
.flatMap(Optional::stream)
.flatMap(provider -> provider.resolveX509Trust(request));
}

private Optional<TrustMaterialIdentityProvider<?>> resolveProvider(KeycloakSession session, String alias) {
IdentityProviderModel model = session.identityProviders().getByAlias(alias);
if (model == null || !model.isEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@

package org.keycloak.broker.trust;

import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.stream.Stream;

import org.keycloak.broker.provider.TrustMaterialIdentityProvider;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.broker.provider.X509TrustMaterial;
import org.keycloak.common.util.PemUtils;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.PublicKeyLoader;
Expand Down Expand Up @@ -50,6 +55,10 @@ public DefaultTrustIdentityProviderConfig getConfig() {

@Override
public Stream<JWK> resolveKeys(TrustMaterialRequest request) {
if (config.isUseX509()
|| Strings.isEmpty(config.getTrustedJwks()) && Strings.isEmpty(config.getTrustedJwksUrl())) {
return Stream.empty();
}
PublicKeyLoader loader = getPublicKeyLoader(request);
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId());
Expand All @@ -61,6 +70,18 @@ public Stream<JWK> resolveKeys(TrustMaterialRequest request) {
return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request);
}

@Override
public Stream<X509TrustMaterial> resolveX509Trust(TrustMaterialRequest request) {
if (!config.isUseX509() || Strings.isEmpty(config.getTrustedCertificates())) {
return Stream.empty();
}

X509Certificate[] certificates = PemUtils.decodeCertificates(config.getTrustedCertificates());
return Stream.of(new X509TrustMaterial(
new LinkedHashSet<>(Arrays.asList(certificates)),
config.getAttestationExtendedKeyUsages()));
}

@Override
public boolean reloadKeys() {
if (!config.isEnabled() || !config.isUseJwksUrl()
Expand Down
Loading
Loading