8000 [OID4VC] Support ETSI Lists of Trusted Entities by dominikschlosser · Pull Request #54522 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
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
25 changes: 22 additions & 3 deletions docs/documentation/server_admin/topics/oid4vci/proofs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ Trusted attester keys are configured per client because different wallets can re

===== Admin Console

. Create or configure a trust-material identity provider, such as **Default Trust**, that exposes the trusted attester public keys or X.509 trust anchors.
. Create or configure a trust-material identity provider, such as **Default Trust** or **ETSI Trust List**, 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 **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.
** For trust from an ETSI TS 119 602 List of Trusted Entities (LoTE), see <<_oid4vci_etsi_trust_list,ETSI trust lists>>.
** For `x5c`-based attestations, enable **Use X.509 attestation trust**, provide a PEM bundle containing the device-vendor attestation CA certificates, 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.
. 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 All @@ -118,12 +119,30 @@ Trusted attester keys are configured per client because different wallets can re

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: 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**, **ETSI Trust List**, 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.

[[_oid4vci_etsi_trust_list]]
===== ETSI trust lists

The **ETSI Trust List** identity provider derives trust from a signed ETSI TS 119 602 V1.1.1 List of Trusted Entities (LoTE), such as the Wallet Providers list of the EUDI Wallet ecosystem. Configure these settings:

Trust list URL:: The URL of the signed list in its JSON Web Signature form.
Trust list signing certificates:: A PEM bundle of the certificates allowed to sign the list. A list is used only when one of these certificates verifies its signature. To rotate the signing key, configure the old and the new certificate.
LoTE type:: The required type of the list, for example `+http://uri.etsi.org/19602/LoTEType/EUWalletProvidersList+`. {project_name} rejects a list whose `LoTEType` value does not match.
Service types:: The service type identifiers whose certificates are trusted, for example `+http://uri.etsi.org/19602/SvcType/WalletSolution/Issuance+`.
Service statuses:: Services with one of these statuses are trusted. Services without a status are always trusted.
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.

One detail is missing: with Service statuses left empty, services that do carry a status are ignored (see servicesWithAStatusAreIgnoredWithoutConfiguredStatuses). Worth spelling that out here.

Refresh interval:: The maximum number of seconds between two downloads, 3600 by default. This value decides how soon a withdrawn certificate stops being trusted.

An attestation must include its signing certificate in the `x5c` header, and that cer 8000 tificate must chain to a CA certificate on the list. Attestations without `x5c` are rejected, and a key cannot be selected by `kid`. End entity certificates on the list are ignored.

{project_name} downloads the list when it is needed and reuses it until the refresh interval or its `NextUpdate` time has passed. When the list cannot be downloaded or verified, trust decisions that depend on it fail until a valid list is available. A list without a `NextUpdate` value has been discontinued by its operator, so none of its certificates are trusted.

{project_name} uses the configured certificates as the only trust source for the list. It does not follow pointers to other lists.

===== Client Attribute

[cols="1,1,2", options="header"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2027,7 +2027,7 @@ 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.
trustedAttestationCertificatesHelp=PEM bundle containing the device-vendor attestation CA certificates. Do not use the general TLS trust store.
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toIdentityProvider } from "../routes/IdentityProvider";
import { toIdentityProviders } from "../routes/IdentityProviders";
import EtsiTrustListSettings from "./EtsiTrustListSettings";

export default function AddEtsiTrustList() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const navigate = useNavigate();
const id = "etsi-trust-list";

const form = useForm<IdentityProviderRepresentation>({
defaultValues: { alias: id },
mode: "onChange",
});
const {
handleSubmit,
formState: { isDirty },
} = form;

const { addAlert, addError } = useAlerts();
const { realm } = useRealm();

const (provider: IdentityProviderRepresentation) => {
try {
await adminClient.identityProviders.create({
...provider,
providerId: id,
});
addAlert(t("createIdentityProviderSuccess"), AlertVariant.success);
void navigate(
toIdentityProvider({
realm,
providerId: id,
alias: provider.alias!,
tab: "settings",
}),
);
} catch (error) {
addError("createIdentityProviderError", error);
}
};

return (
<>
<ViewHeader
titleKey={t("addIdentityProvider", { provider: "ETSI Trust List" })}
/>
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
>
>
<EtsiTrustListSettings />

<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("add")}
</Button>
<Button
variant="link"
data-testid="cancel"
component={(props) => (
<Link {...props} to={toIdentityProviders({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
}
31 changes: 29 additions & 2 deletions js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { JWTAuthorizationGrantAssertionSettings } from "./JWTAuthorizationGrantA
import JWTAuthorizationGrantSettings from "./JWTAuthorizationGrantSettings";
import { DefaultSwitchControl } from "../../components/SwitchControl";
import DefaultTrustSettings from "./DefaultTrustSettings";
import EtsiTrustListSettings from "./EtsiTrustListSettings";
import { IdpDomainsTab } from "./IdpDomainsTab";
import { IdpOrganizationsTab } from "./IdpOrganizationsTab";
import Oid4VpSettings from "./Oid4VpSettings";
Expand Down Expand Up @@ -491,6 +492,7 @@ export default function DetailSettings() {
"jwt-authorization-grant",
);
const isDefaultTrust = provider.providerId === "default-trust";
const isEtsiTrustList = provider.providerId === "etsi-trust-list";
const isOid4vp = provider.providerId === "oid4vp";
const isSocial = !isOIDC && !isSAML && !isOAuth2;
const isJWTAuthorizationGrantSupported =
Expand Down Expand Up @@ -531,6 +533,7 @@ export default function DetailSettings() {
isKubernetes ||
isJWTAuthorizationGrant ||
isDefaultTrust ||
isEtsiTrustList ||
isOid4vp,
panel: (
<FormAccess
Expand Down Expand Up @@ -692,6 +695,25 @@ export default function DetailSettings() {
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isEtsiTrustList,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
>
>
<EtsiTrustListSettings />
<FixedButtonsGroup
name="idp-details"
isSubmit
reset={reset}
isDisabled={!isDirty}
/>
</Form>
),
},
{
title: t("samlSettings"),
isHidden: !isSAML,
Expand All @@ -713,7 +735,11 @@ export default function DetailSettings() {
{
title: t("advancedSettings"),
isHidden:
isSPIFFE || isKubernetes || isJWTAuthorizationGrant || isDefaultTrust,
isSPIFFE ||
isKubernetes ||
isJWTAuthorizationGrant ||
isDefaultTrust ||
isEtsiTrustList,
panel: (
<FormAccess
role="manage-identity-providers"
Expand Down Expand Up @@ -808,7 +834,8 @@ export default function DetailSettings() {
isSPIFFE ||
isKubernetes ||
isJWTAuthorizationGrant ||
isDefaultTrust
isDefaultTrust ||
isEtsiTrustList
}
data-testid="mappers-tab"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { DynamicComponents } from "../../components/dynamic/DynamicComponents";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import type { IdentityProviderParams } from "../routes/IdentityProvider";

const PROVIDER_ID = "etsi-trust-list";

export default function EtsiTrustListSettings() {
const { t } = useTranslation();
const { tab } = useParams<IdentityProviderParams>();
const serverInfo = useServerInfo();

const properties = useMemo(
() =>
serverInfo.componentTypes?.[
"org.keycloak.broker.provider.IdentityProvider"
]?.find(({ id }) => id === PROVIDER_ID)?.properties ?? [],
[serverInfo],
);

return (
<>
<TextControl
name="alias"
label={t("alias")}
labelIcon={t("aliasHelp")}
readOnly={tab === "settings"}
rules={{
required: t("required"),
}}
/>
<DynamicComponents stringify properties={properties} />
</>
);
}
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/identity-providers/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IdentityProviderSpiffeRoute } from "./routes/IdentityProviderSpiffe";
import { IdentityProviderKubernetesRoute } from "./routes/IdentityProviderKubernetes";
import { IdentityProviderDefaultTrustRoute } from "./routes/IdentityProviderDefaultTrust";
import { IdentityProviderOid4VpRoute } from "./routes/IdentityProviderOid4Vp";
import { IdentityProviderEtsiTrustListRoute } from "./routes/IdentityProviderEtsiTrustList";
import { IdentityProvidersRoute } from "./routes/IdentityProviders";
import { IdentityProviderAddMapperRoute } from "./routes/AddMapper";
import { IdentityProviderEditMapperRoute } from "./routes/EditMapper";
Expand All @@ -25,6 +26,7 @@ const routes: AppRouteObject[] = [
IdentityProviderKubernetesRoute,
IdentityProviderDefaultTrustRoute,
IdentityProviderOid4VpRoute,
IdentityProviderEtsiTrustListRoute,
IdentityProviderKeycloakOidcRoute,
IdentityProviderCreateRoute,
IdentityProviderRoute,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";

export type IdentityProviderEtsiTrustListParams = { realm: string };

const AddEtsiTrustList = lazy(() => import("../add/AddEtsiTrustList"));

export const IdentityProviderEtsiTrustListRoute: AppRouteObject = {
path: "/:realm/identity-providers/etsi-trust-list/add",
element: <AddEtsiTrustList />,
handle: {
access: "manage-identity-providers",
breadcrumb: (t) => t("addProvider"),
},
};

export const toIdentityProviderEtsiTrustList = (
params: IdentityProviderEtsiTrustListParams,
): Partial<Path> => ({
pathname: generateEncodedPath(
IdentityProviderEtsiTrustListRoute.path,
params,
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
/**
* 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 trustAnchors CA certificates for one trust domain. Path validation ends at a listed
* certificate, so a subordinate CA is a valid anchor
* @param requiredExtendedKeyUsages extended key usage OIDs of which the end entity certificate must
* contain at least one, an empty list imposes no restriction
*/
Expand All @@ -49,15 +50,5 @@ private static void validateTrustAnchor(X509Certificate certificate) {
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 @@ -182,7 +182,6 @@ public Stream<X509TrustMaterial> resolveX509Trust(TrustMaterialRequest request)
// How trusted issuer material is resolved for a presented credential. The provider acts as its
// own trust material, either delegating to the configured trust material identity providers or
// serving the inline trusted issuer JWKS.
// TODO add a trust list backed resolver (e.g. ETSI), selected from configuration.
protected TrustedSdJwtIssuerResolver trustedIssuerResolver() {
return new TrustMaterialSdJwtIssuerResolver(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public void validate(RealmModel realm) {
getRequiredExtendedKeyUsages());
} catch (RuntimeException e) {
throw new IllegalArgumentException(
"Trusted X.509 certificates must be a valid PEM bundle of self-signed CA roots", e);
"Trusted X.509 certificates must be a valid PEM bundle of CA certificates", e);
}
} else {
if (hasTrustedCertificates) {
Expand Down
Loading
Loading
0