mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
[OID4VCI] Conformance Test Fixes (#44439)
closes #44659 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
parent
25cbc45002
commit
4dd68c0316
@ -18,6 +18,9 @@ package org.keycloak.crypto;
|
||||
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class AsymmetricSignatureSignerContext implements SignatureSignerContext {
|
||||
|
||||
@ -54,4 +57,14 @@ public class AsymmetricSignatureSignerContext implements SignatureSignerContext
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<X509Certificate> getCertificateChain() {
|
||||
if (key.getCertificateChain() != null && !key.getCertificateChain().isEmpty()) {
|
||||
return key.getCertificateChain();
|
||||
} else if (key.getCertificate() != null) {
|
||||
return Collections.singletonList(key.getCertificate());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -16,6 +16,9 @@
|
||||
*/
|
||||
package org.keycloak.crypto;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
|
||||
public interface SignatureSignerContext {
|
||||
|
||||
String getKid();
|
||||
@ -26,4 +29,15 @@ public interface SignatureSignerContext {
|
||||
|
||||
byte[] sign(byte[] data) throws SignatureException;
|
||||
|
||||
/**
|
||||
* Returns the X.509 certificate chain associated with this signer, if available.
|
||||
* Returns null if certificates are not available (e.g., for MAC-based signers).
|
||||
* This allows access to certificates without requiring a separate KeyWrapper parameter.
|
||||
*
|
||||
* @return List of X.509 certificates, or null if not available
|
||||
*/
|
||||
default List<X509Certificate> getCertificateChain() {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -147,6 +147,8 @@ Create a JSON file (e.g., `realm-attributes.json`) with the following content:
|
||||
|
||||
The attributes section contains issuer-specific metadata:
|
||||
- **preAuthorizedCodeLifespanS** – Defines how long pre-authorized codes remain valid (in seconds).
|
||||
- **oid4vc.attestation.trusted_keys** – JSON array of trusted JWK (JSON Web Key) objects for attestation proof validation. Each JWK must include a `kid` (key ID) field. These keys take precedence over realm session keys when there are conflicts. Useful for configuring additional trusted keys beyond the realm's default keys. Format: JSON array of JWK objects, e.g., `[{"kid":"key1","kty":"EC",...},{"kid":"key2","kty":"RSA",...}]`.
|
||||
- **oid4vc.attestation.trusted_key_ids** – Comma-separated list of key IDs from the realm's key providers to use for attestation proof validation. Keys are looked up by their `kid` regardless of enabled status, allowing the use of disabled keys that are not exposed in well-known endpoints. This attribute takes the highest priority when merging trusted keys. Format: comma-separated list of key IDs, e.g., `key-id-1,key-id-2,key-id-3`.
|
||||
|
||||
==== Import Realm Attributes
|
||||
|
||||
|
||||
@ -443,7 +443,7 @@ Make sure to update your code to `await` these methods.
|
||||
|
||||
== A secure context is now required
|
||||
|
||||
Keycloak JS now requires a link:https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts[secure context] to run. The reason for this is that the library now uses the Web Crypto API for various cryptographic functions. This API is only available in secure contexts, which are contexts that are served over HTTPS, `localhost` or a `.localhost` domain. If you are using the library in a non-secure context you'll need to update your development environment to use a secure context.
|
||||
Keycloak JS now requires a link:https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts[secure context] to run. The reason for this is that the library now uses the Web Crypto API for various cryptographic functions. This API is only available in secure contexts, which are contexts that are served over HTTPS, `localhost` or a `.localhost` domain. If you are using the library in a non-secure context you'll need to update your development environment to use a secure context.
|
||||
|
||||
= Stricter startup behavior for build-time options
|
||||
|
||||
|
||||
@ -39,6 +39,8 @@ public final class OID4VCIConstants {
|
||||
// --- Keybinding/Credential Builder ---
|
||||
public static final String SOURCE_ENDPOINT = "source_endpoint";
|
||||
public static final String BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE = "batch_credential_issuance.batch_size";
|
||||
public static final String TRUSTED_KEYS_REALM_ATTR = "oid4vc.attestation.trusted_keys";
|
||||
public static final String TRUSTED_KEY_IDS_REALM_ATTR = "oid4vc.attestation.trusted_key_ids";
|
||||
|
||||
public static final RoleRepresentation CREDENTIAL_OFFER_CREATE =
|
||||
new RoleRepresentation("credential-offer-create", "Allow credential offer creation", false);
|
||||
|
||||
@ -25,15 +25,22 @@ import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
|
||||
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.Claim;
|
||||
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
|
||||
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
@ -81,9 +88,74 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
|
||||
throw getInvalidRequestException("no valid authorization details found");
|
||||
}
|
||||
|
||||
// For authorization code flow, create CredentialOfferState if credential identifiers are present
|
||||
// This allows credential requests with credential_identifier to find the associated offer state
|
||||
createOfferStateForAuthorizationCodeFlow(userSession, clientSessionCtx, authDetailsResponse);
|
||||
|
||||
return authDetailsResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates CredentialOfferState for authorization code flow when credential identifiers are generated.
|
||||
* This is only done for authorization code flow (not pre-authorized flow which already has an offer state).
|
||||
* Processes all OID4VC authorization details to support multiple credential requests.
|
||||
*/
|
||||
private void createOfferStateForAuthorizationCodeFlow(UserSessionModel userSession, ClientSessionContext clientSessionCtx,
|
||||
List<AuthorizationDetailsResponse> authDetailsResponse) {
|
||||
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
||||
ClientModel client = clientSession != null ? clientSession.getClient() : null;
|
||||
UserModel user = userSession != null ? userSession.getUser() : null;
|
||||
|
||||
if (client == null || user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if we're in pre-authorized code flow (it already has an offer state that will be updated)
|
||||
// Pre-authorized flow sets VC_ISSUANCE_FLOW note on the client session
|
||||
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
|
||||
if (vcIssuanceFlow != null && vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
|
||||
logger.debugf("Skipping offer state creation for pre-authorized code flow (offer state already exists and will be updated)");
|
||||
return;
|
||||
}
|
||||
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
|
||||
// Process all OID4VC authorization details to create offer states for each credential
|
||||
for (AuthorizationDetailsResponse authDetail : authDetailsResponse) {
|
||||
if (authDetail instanceof OID4VCAuthorizationDetailsResponse oid4vcDetail) {
|
||||
if (oid4vcDetail.getCredentialIdentifiers() != null && !oid4vcDetail.getCredentialIdentifiers().isEmpty()) {
|
||||
for (String credentialId : oid4vcDetail.getCredentialIdentifiers()) {
|
||||
// Check if offer state already exists
|
||||
CredentialOfferStorage.CredentialOfferState existingState = offerStorage.findOfferStateByCredentialId(session, credentialId);
|
||||
|
||||
if (existingState == null) {
|
||||
// Create a new offer state for authorization code flow
|
||||
CredentialsOffer credOffer = new CredentialsOffer()
|
||||
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
|
||||
.setCredentialConfigurationIds(List.of(oid4vcDetail.getCredentialConfigurationId()));
|
||||
|
||||
// Use a reasonable expiration time (e.g., 1 hour)
|
||||
int expiration = Time.currentTime() + 3600;
|
||||
CredentialOfferStorage.CredentialOfferState offerState = new CredentialOfferStorage.CredentialOfferState(
|
||||
credOffer, client.getClientId(), user.getUsername(), expiration);
|
||||
offerState.setAuthorizationDetails(oid4vcDetail);
|
||||
|
||||
offerStorage.putOfferState(session, offerState);
|
||||
logger.debugf("Created credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
|
||||
client.getClientId(), user.getUsername(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
|
||||
} else {
|
||||
// Update existing offer state with new authorization details (e.g., if same credential identifier is reused)
|
||||
existingState.setAuthorizationDetails(oid4vcDetail);
|
||||
offerStorage.replaceOfferState(session, existingState);
|
||||
logger.debugf("Updated existing credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
|
||||
client.getClientId(), user.getUsername(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<AuthorizationDetail> parseAuthorizationDetails(String authorizationDetailsParam) {
|
||||
try {
|
||||
return JsonSerialization.readValue(authorizationDetailsParam, new TypeReference<List<AuthorizationDetail>>() {
|
||||
|
||||
@ -100,7 +100,6 @@ import org.keycloak.protocol.oid4vc.model.NonceResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofType;
|
||||
import org.keycloak.protocol.oid4vc.model.Proofs;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
@ -166,6 +165,16 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
private Cors cors;
|
||||
|
||||
/**
|
||||
* Cached authentication result to prevent DPoP proof reuse.
|
||||
* <p>
|
||||
* This cache ensures that when authentication is performed multiple times during
|
||||
* a single request processing (e.g., when issuing multiple credentials), the same
|
||||
* authentication result is reused instead of re-authenticating, which would allow
|
||||
* the same DPoP proof to be used multiple times.
|
||||
*/
|
||||
private AuthenticationManager.AuthResult cachedAuthResult;
|
||||
|
||||
private static final String CODE_LIFESPAN_REALM_ATTRIBUTE_KEY = "preAuthorizedCodeLifespanS";
|
||||
private static final int DEFAULT_CODE_LIFESPAN_S = 30;
|
||||
|
||||
@ -567,7 +576,9 @@ public class OID4VCIssuerEndpoint {
|
||||
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
|
||||
|
||||
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
|
||||
AccessToken accessToken = bearerTokenAuthenticator.authenticate().token();
|
||||
// Use getAuthResult() instead of bearerTokenAuthenticator.authenticate() directly
|
||||
// This ensures we benefit from the cachedAuthResult caching that prevents DPoP proof reuse
|
||||
AccessToken accessToken = getAuthResult().token();
|
||||
if (Arrays.stream(accessToken.getScope().split(" "))
|
||||
.noneMatch(tokenScope -> tokenScope.equals(requestedCredential.getScope()))) {
|
||||
LOGGER.debugf("Scope check failure: required scope = %s, " +
|
||||
@ -768,8 +779,14 @@ public class OID4VCIssuerEndpoint {
|
||||
} else {
|
||||
// Issue credentials for each proof
|
||||
Proofs originalProofs = credentialRequestVO.getProofs();
|
||||
// Determine the proof type from the original proofs
|
||||
String proofType = originalProofs != null ? originalProofs.getProofType() : null;
|
||||
|
||||
for (String currentProof : allProofs) {
|
||||
credentialRequestVO.setProofs(new Proofs().setJwt(List.of(currentProof)));
|
||||
Proofs proofForIteration = new Proofs();
|
||||
proofForIteration.setProofByType(proofType, currentProof);
|
||||
// Creating credential with keybinding to the current proof
|
||||
credentialRequestVO.setProofs(proofForIteration);
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
|
||||
responseVO.addCredential(theCredential);
|
||||
}
|
||||
@ -830,7 +847,8 @@ public class OID4VCIssuerEndpoint {
|
||||
return credentialRequest;
|
||||
} catch (JsonProcessingException e) {
|
||||
String errorMessage = "Failed to parse JSON request: " + e.getMessage();
|
||||
LOGGER.debug(errorMessage);
|
||||
LOGGER.errorf(e, "JSON parsing failed. Request payload length: %d",
|
||||
requestPayload != null ? requestPayload.length() : 0);
|
||||
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
}
|
||||
@ -964,6 +982,8 @@ public class OID4VCIssuerEndpoint {
|
||||
credentialRequest.setProofs(proofsArray);
|
||||
credentialRequest.setProof(null);
|
||||
}
|
||||
|
||||
validateProofTypes(credentialRequest.getProofs());
|
||||
}
|
||||
|
||||
private String selectKeyManagementAlg(CredentialResponseEncryptionMetadata metadata, JWK jwk) {
|
||||
@ -993,20 +1013,32 @@ public class OID4VCIssuerEndpoint {
|
||||
}
|
||||
|
||||
private List<String> getAllProofs(CredentialRequest credentialRequestVO) {
|
||||
List<String> allProofs = new ArrayList<>();
|
||||
|
||||
Proofs proofs = credentialRequestVO.getProofs();
|
||||
if (proofs == null) {
|
||||
return allProofs; // No proofs provided
|
||||
return new ArrayList<>(); // No proofs provided
|
||||
}
|
||||
|
||||
if (proofs.getJwt() == null || proofs.getJwt().isEmpty()) {
|
||||
// Validation already happened in normalizeProofFields, so we can safely extract proofs
|
||||
return proofs.getAllProofs();
|
||||
}
|
||||
|
||||
private void validateProofTypes(Proofs proofs) {
|
||||
if (proofs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasJwtProofs = hasProofEntries(proofs.getJwt());
|
||||
boolean hasAttestationProofs = hasProofEntries(proofs.getAttestation());
|
||||
|
||||
if (hasJwtProofs && hasAttestationProofs) {
|
||||
LOGGER.debug("The 'proofs' object must not contain multiple proof types.");
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_PROOF,
|
||||
"The 'proofs' object must contain exactly one proof type with non-empty array."));
|
||||
"The 'proofs' object must not contain multiple proof types."));
|
||||
}
|
||||
}
|
||||
|
||||
allProofs.addAll(proofs.getJwt());
|
||||
return allProofs;
|
||||
private boolean hasProofEntries(List<String> proofs) {
|
||||
return proofs != null && !proofs.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1165,6 +1197,10 @@ public class OID4VCIssuerEndpoint {
|
||||
}
|
||||
|
||||
private AuthenticationManager.AuthResult getAuthResult() {
|
||||
if (cachedAuthResult != null) {
|
||||
return cachedAuthResult;
|
||||
}
|
||||
|
||||
AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
|
||||
if (authResult == null) {
|
||||
throw new CorsErrorResponseException(
|
||||
@ -1175,7 +1211,7 @@ public class OID4VCIssuerEndpoint {
|
||||
}
|
||||
|
||||
// Validate DPoP nonce if present in the DPoP proof
|
||||
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
|
||||
DPoP dPoP = (DPoP) session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE);
|
||||
if (dPoP != null) {
|
||||
Object nonceClaim = Optional.ofNullable(dPoP.getOtherClaims())
|
||||
.map(m -> m.get("nonce"))
|
||||
@ -1201,16 +1237,8 @@ public class OID4VCIssuerEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
// get the auth result from the authentication manager
|
||||
private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) {
|
||||
AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
|
||||
if (authResult == null) {
|
||||
throw errorResponse;
|
||||
}
|
||||
return authResult;
|
||||
cachedAuthResult = authResult;
|
||||
return cachedAuthResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1245,7 +1273,7 @@ public class OID4VCIssuerEndpoint {
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO);
|
||||
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel);
|
||||
|
||||
// Enforce key binding prior to signing if necessary
|
||||
enforceKeyBindingIfProofProvided(vcIssuanceContext);
|
||||
@ -1299,7 +1327,8 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
// builds the unsigned credential by applying all protocol mappers.
|
||||
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
|
||||
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) {
|
||||
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO,
|
||||
CredentialScopeModel credentialScopeModel) {
|
||||
|
||||
// Compute issuance date and apply correlation-mitigation according to realm configuration
|
||||
Instant issuance = Instant.ofEpochMilli(timeProvider.currentTimeMillis());
|
||||
@ -1307,7 +1336,7 @@ public class OID4VCIssuerEndpoint {
|
||||
Instant normalizedIssuance = timeClaimNormalizer.normalize(issuance);
|
||||
|
||||
// Compute expiration date from client scope configuration and normalize it
|
||||
CredentialScopeModel clientScopeModel = getClientScopeModel(credentialConfig);
|
||||
CredentialScopeModel clientScopeModel = credentialScopeModel;
|
||||
Integer expiryInSeconds = clientScopeModel.getExpiryInSeconds();
|
||||
Instant expiration = normalizedIssuance.plusSeconds(expiryInSeconds);
|
||||
Instant normalizedExpiration = timeClaimNormalizer.normalize(expiration);
|
||||
@ -1354,9 +1383,9 @@ public class OID4VCIssuerEndpoint {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each JWT proof if present
|
||||
if (proofs.getJwt() != null && !proofs.getJwt().isEmpty()) {
|
||||
validateProofs(vcIssuanceContext, ProofType.JWT);
|
||||
// Validate each proof type that is present
|
||||
for (String proofType : proofs.getPresentProofTypes()) {
|
||||
validateProofs(vcIssuanceContext, proofType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,8 +19,10 @@ package org.keycloak.protocol.oid4vc.issuance.credentialbuilder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.keycloak.OID4VCConstants;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
@ -78,7 +80,16 @@ public class SdJwtCredentialBuilder implements CredentialBuilder {
|
||||
claimSet.put(ISSUER_CLAIM, credentialBuildConfig.getCredentialIssuer());
|
||||
claimSet.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, credentialBuildConfig.getCredentialType());
|
||||
|
||||
// jti, nbf, iat and exp are all optional. So need to be set by a protocol mapper if needed.
|
||||
// Set exp claim from verifiable credential expiration date
|
||||
// expiry is optional, but should be set if available to comply with HAIP
|
||||
// see: https://openid.github.io/OpenID4VC-HAIP/openid4vc-high-assurance-interoperability-profile-wg-draft.html#section-6.1
|
||||
// Only set if not already set by a protocol mapper
|
||||
if (!claimSet.containsKey(OID4VCConstants.CLAIM_NAME_EXP)) {
|
||||
Optional.ofNullable(verifiableCredential.getExpirationDate())
|
||||
.ifPresent(d -> claimSet.put(OID4VCConstants.CLAIM_NAME_EXP, d.getEpochSecond()));
|
||||
}
|
||||
|
||||
// jti, nbf, and iat are all optional. So need to be set by a protocol mapper if needed.
|
||||
// see: https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-registered-jwt-claims
|
||||
|
||||
// Add the configured number of decoys
|
||||
|
||||
@ -17,17 +17,52 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.constants.OID4VCIConstants;
|
||||
import org.keycloak.crypto.KeyType;
|
||||
import org.keycloak.jose.jwk.JSONWebKeySet;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.jose.jwk.JWKBuilder;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofType;
|
||||
import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Factory for creating AttestationProofValidator instances with configurable trusted keys.
|
||||
* Trusted keys are loaded from multiple sources with the following priority (highest to lowest):
|
||||
* <ol>
|
||||
* <li>Keys by ID from realm attribute 'oid4vc.attestation.trusted_key_ids': Keys referenced by their keyId
|
||||
* from the realm's key providers (can include disabled keys, not exposed in well-known endpoints)</li>
|
||||
* <li>Keys from realm attribute 'oid4vc.attestation.trusted_keys': Explicit JWK JSON array</li>
|
||||
* <li>Realm session keys (default): All enabled keys from the realm's key providers (exposed in well-known endpoints)</li>
|
||||
* </ol>
|
||||
* Keys from higher priority sources take precedence when there are conflicts (same kid).
|
||||
* This approach allows using realm keys as a default while supporting additional keys via realm attributes,
|
||||
* including disabled keys that are not exposed in well-known endpoints.
|
||||
*
|
||||
* @author <a href="mailto:Rodrick.Awambeng@adorsys.com">Rodrick Awambeng</a>
|
||||
*/
|
||||
public class AttestationProofValidatorFactory implements ProofValidatorFactory {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(AttestationProofValidatorFactory.class);
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ProofType.ATTESTATION;
|
||||
@ -35,10 +70,199 @@ public class AttestationProofValidatorFactory implements ProofValidatorFactory {
|
||||
|
||||
@Override
|
||||
public ProofValidator create(KeycloakSession session) {
|
||||
// TODO: Load trusted keys from config, DB, or env
|
||||
Map<String, JWK> trustedKeys = Map.of(); // empty for now
|
||||
|
||||
Map<String, JWK> trustedKeys = loadTrustedKeysFromRealm(session);
|
||||
AttestationKeyResolver resolver = new StaticAttestationKeyResolver(trustedKeys);
|
||||
return new AttestationProofValidator(session, resolver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads trusted keys by merging keys from multiple sources with priority:
|
||||
* 1. Keys by ID from realm attribute (highest priority, can include disabled keys)
|
||||
* 2. Keys from realm attribute JSON (explicit JWK)
|
||||
* 3. Enabled keys from session (lowest priority, exposed in well-known endpoints)
|
||||
*
|
||||
* @param session The Keycloak session
|
||||
* @return Map of trusted keys keyed by kid, or empty map if realm is null
|
||||
*/
|
||||
private Map<String, JWK> loadTrustedKeysFromRealm(KeycloakSession session) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
if (realm == null) {
|
||||
logger.debugf("No realm available, returning empty trusted keys map");
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// Load keys from session as default/fallback (lowest priority)
|
||||
Map<String, JWK> sessionKeys = loadKeysFromSession(session, realm);
|
||||
|
||||
// Load keys from realm attribute JSON (medium priority)
|
||||
Map<String, JWK> attributeKeys = loadKeysFromRealmAttribute(realm);
|
||||
|
||||
// Load keys by ID from realm attribute (highest priority, can include disabled keys)
|
||||
Map<String, JWK> keyIdsKeys = loadKeysByKeyIds(session, realm);
|
||||
|
||||
// Merge with priority: keyIdsKeys > attributeKeys > sessionKeys
|
||||
Map<String, JWK> mergedKeys = new HashMap<>(sessionKeys);
|
||||
mergedKeys.putAll(attributeKeys);
|
||||
mergedKeys.putAll(keyIdsKeys);
|
||||
|
||||
if (mergedKeys.isEmpty()) {
|
||||
logger.debugf("No trusted keys found for attestation proof validation");
|
||||
} else {
|
||||
logger.debugf("Loaded %d trusted keys for attestation proof validation (%d from session, %d from realm attribute JSON, %d from realm attribute key IDs)",
|
||||
mergedKeys.size(), sessionKeys.size(), attributeKeys.size(), keyIdsKeys.size());
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(mergedKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads keys from Keycloak session by reusing JWKSServerUtils.getRealmJwks().
|
||||
* This provides a default set of trusted keys from the realm's key providers.
|
||||
* Converts the result to a Map keyed by kid for easier lookup and merging.
|
||||
*/
|
||||
private Map<String, JWK> loadKeysFromSession(KeycloakSession session, RealmModel realm) {
|
||||
try {
|
||||
JSONWebKeySet keySet = JWKSServerUtils.getRealmJwks(session, realm);
|
||||
if (keySet == null || keySet.getKeys() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
return Stream.of(keySet.getKeys())
|
||||
.filter(jwk -> jwk != null && jwk.getKeyId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
JWK::getKeyId,
|
||||
jwk -> jwk,
|
||||
(existing, replacement) -> existing // Keep first occurrence if duplicate kids
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.warnf(e, "Failed to load keys from session for realm '%s'", realm.getName());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads trusted keys from realm attribute.
|
||||
* These keys take precedence over session keys when merged.
|
||||
*/
|
||||
private Map<String, JWK> loadKeysFromRealmAttribute(RealmModel realm) {
|
||||
String trustedKeysJson = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
|
||||
if (trustedKeysJson == null || trustedKeysJson.trim().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
try {
|
||||
return parseTrustedKeys(trustedKeysJson);
|
||||
} catch (Exception e) {
|
||||
logger.warnf(e, "Failed to parse trusted keys from realm attribute '%s'", OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads trusted keys by key IDs from realm attribute.
|
||||
* Keys are looked up from realm's key providers by their keyId, regardless of enabled status.
|
||||
* This allows using disabled keys that are not exposed in well-known endpoints.
|
||||
*
|
||||
* @param session The Keycloak session
|
||||
* @param realm The realm
|
||||
* @return Map of trusted keys keyed by kid, or empty map if no key IDs are configured
|
||||
*/
|
||||
private Map<String, JWK> loadKeysByKeyIds(KeycloakSession session, RealmModel realm) {
|
||||
String trustedKeyIds = realm.getAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
|
||||
if (trustedKeyIds == null || trustedKeyIds.trim().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// Parse comma-separated list of key IDs
|
||||
Set<String> keyIds = Arrays.stream(trustedKeyIds.split(","))
|
||||
.map(String::trim)
|
||||
.filter(id -> !id.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (keyIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<String, JWK> keyMap = new HashMap<>();
|
||||
|
||||
// Get all keys from realm (including disabled ones) and convert to JWK format
|
||||
session.keys().getKeysStream(realm)
|
||||
.filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null)
|
||||
.forEach(key -> {
|
||||
try {
|
||||
JWKBuilder builder = JWKBuilder.create()
|
||||
.kid(key.getKid())
|
||||
.algorithm(key.getAlgorithmOrDefault());
|
||||
List<X509Certificate> certificates = Optional.ofNullable(key.getCertificateChain())
|
||||
.filter(certs -> !certs.isEmpty())
|
||||
.orElseGet(() -> Optional.ofNullable(key.getCertificate())
|
||||
.map(Collections::singletonList)
|
||||
.orElseGet(Collections::emptyList));
|
||||
JWK jwk = null;
|
||||
if (Objects.equals(key.getType(), KeyType.RSA)) {
|
||||
jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse());
|
||||
} else if (Objects.equals(key.getType(), KeyType.EC)) {
|
||||
jwk = builder.ec(key.getPublicKey(), certificates, key.getUse());
|
||||
} else if (Objects.equals(key.getType(), KeyType.OKP)) {
|
||||
jwk = builder.okp(key.getPublicKey(), key.getUse());
|
||||
}
|
||||
if (jwk != null) {
|
||||
keyMap.put(key.getKid(), jwk);
|
||||
} else {
|
||||
logger.debugf("Unsupported key type '%s' for key '%s'", key.getType(), key.getKid());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warnf(e, "Failed to convert key '%s' to JWK format", key.getKid());
|
||||
}
|
||||
});
|
||||
|
||||
// Log any key IDs that were not found
|
||||
Set<String> foundKeyIds = keyMap.keySet();
|
||||
Set<String> missingKeyIds = keyIds.stream()
|
||||
.filter(id -> !foundKeyIds.contains(id))
|
||||
.collect(Collectors.toSet());
|
||||
if (!missingKeyIds.isEmpty()) {
|
||||
logger.warnf("The following key IDs from realm attribute '%s' were not found in realm key providers: %s",
|
||||
OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR, missingKeyIds);
|
||||
}
|
||||
|
||||
if (!keyMap.isEmpty()) {
|
||||
logger.debugf("Loaded %d trusted keys by key ID from realm attribute (including potentially disabled keys)", keyMap.size());
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(keyMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses trusted keys from JSON string.
|
||||
* Expected format: JSON array of JWK objects, each with a 'kid' field.
|
||||
*/
|
||||
private Map<String, JWK> parseTrustedKeys(String json) {
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
try {
|
||||
List<JWK> keys = JsonSerialization.mapper.readValue(json, new TypeReference<List<JWK>>() {
|
||||
});
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<String, JWK> keyMap = new HashMap<>();
|
||||
for (JWK key : keys) {
|
||||
String kid = key.getKeyId();
|
||||
if (kid == null || kid.trim().isEmpty()) {
|
||||
logger.warnf("Skipping JWK without 'kid' field in trusted keys configuration");
|
||||
continue;
|
||||
}
|
||||
keyMap.put(kid, key);
|
||||
}
|
||||
|
||||
logger.debugf("Loaded %d trusted keys from realm attribute JSON", keyMap.size());
|
||||
return Collections.unmodifiableMap(keyMap);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid JSON format for trusted keys: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,10 +55,12 @@ import org.keycloak.jose.jws.Algorithm;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.jose.jws.JWSInputException;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
||||
import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel;
|
||||
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
|
||||
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
|
||||
@ -129,6 +131,9 @@ public class AttestationValidatorUtil {
|
||||
} else if (header.getKeyId() != null) {
|
||||
JWK resolvedJwk = keyResolver.resolveKey(header.getKeyId(), rawHeader,
|
||||
JsonSerialization.mapper.convertValue(attestationBody, Map.class));
|
||||
if (resolvedJwk == null) {
|
||||
throw new VCIssuerException("Key with kid '" + header.getKeyId() + "' not found in trusted key registry");
|
||||
}
|
||||
verifier = verifierFromResolvedJWK(resolvedJwk, header.getAlgorithm().name(), keycloakSession);
|
||||
} else {
|
||||
throw new VCIssuerException("Neither x5c nor kid present in attestation JWT header");
|
||||
@ -157,15 +162,6 @@ public class AttestationValidatorUtil {
|
||||
throw new VCIssuerException("Missing 'iat' claim in attestation");
|
||||
}
|
||||
|
||||
if (attestationBody.getNonce() == null) {
|
||||
throw new VCIssuerException("Missing 'nonce' in attestation");
|
||||
}
|
||||
|
||||
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
|
||||
if (cNonceHandler == null) {
|
||||
throw new VCIssuerException("No CNonceHandler available");
|
||||
}
|
||||
|
||||
// Get resistance level requirements from configuration
|
||||
KeyAttestationsRequired attestationRequirements = getAttestationRequirements(vcIssuanceContext);
|
||||
|
||||
@ -184,14 +180,34 @@ public class AttestationValidatorUtil {
|
||||
"user_authentication");
|
||||
}
|
||||
|
||||
cNonceHandler.verifyCNonce(
|
||||
attestationBody.getNonce(),
|
||||
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(
|
||||
keycloakSession.getContext())),
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
|
||||
OID4VCIssuerWellKnownProvider.getNonceEndpoint(
|
||||
keycloakSession.getContext()))
|
||||
);
|
||||
KeycloakContext keycloakContext = keycloakSession.getContext();
|
||||
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
|
||||
|
||||
// If CNonceHandler is available, nonce endpoint exists and nonce is required
|
||||
boolean nonceRequired = cNonceHandler != null;
|
||||
|
||||
if (nonceRequired && attestationBody.getNonce() == null) {
|
||||
throw new VCIssuerException("Missing 'nonce' in attestation");
|
||||
}
|
||||
|
||||
// Validate nonce if present. If provided, it must correspond to a nonce value provided by Keycloak.
|
||||
if (attestationBody.getNonce() != null) {
|
||||
if (cNonceHandler == null) {
|
||||
throw new VCIssuerException("No CNonceHandler available");
|
||||
}
|
||||
|
||||
try {
|
||||
cNonceHandler.verifyCNonce(
|
||||
attestationBody.getNonce(),
|
||||
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(keycloakContext)),
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
|
||||
OID4VCIssuerWellKnownProvider.getNonceEndpoint(keycloakContext))
|
||||
);
|
||||
} catch (VerificationException e) {
|
||||
throw new VCIssuerException(ErrorType.INVALID_NONCE,
|
||||
"The key attestation uses an invalid nonce", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Store attested keys in context for later use
|
||||
if (attestationBody.getAttestedKeys() != null) {
|
||||
|
||||
@ -17,6 +17,14 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.signing;
|
||||
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.crypto.SignatureSignerContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBody;
|
||||
@ -46,6 +54,52 @@ public class SdJwtCredentialSigner extends AbstractCredentialSigner<String> {
|
||||
}
|
||||
|
||||
LOGGER.debugf("Sign credentials to sd-jwt format.");
|
||||
return sdJwtCredentialBody.sign(getSigner(credentialBuildConfig));
|
||||
|
||||
// Get the signer first to ensure we use the exact same key that will sign the credential
|
||||
SignatureSignerContext signer = getSigner(credentialBuildConfig);
|
||||
|
||||
// Add x5c certificate chain to the header if available (required by HAIP-6.1.1)
|
||||
// See: https://openid.github.io/OpenID4VC-HAIP/openid4vc-high-assurance-interoperability-profile-wg-draft.html#section-6.1.1
|
||||
addX5cHeader(sdJwtCredentialBody, signer);
|
||||
|
||||
return sdJwtCredentialBody.sign(signer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds x5c certificate chain to the IssuerSignedJWT header if available.
|
||||
* This is required by HAIP-6.1.1 for SD-JWT credentials.
|
||||
* <p>
|
||||
* Uses the certificate chain from the signer to ensure we use the exact same key
|
||||
* that will be used for signing, following Keycloak's established pattern.
|
||||
* <p>
|
||||
* See <a href="https://openid.github.io/OpenID4VC-HAIP/openid4vc-high-assurance-interoperability-profile-wg-draft.html#section-6.1.1">HAIP Section 6.1.1</a>
|
||||
* for the requirement on issuer identification and key resolution.
|
||||
*
|
||||
* @param sdJwtCredentialBody The SD-JWT credential body to add x5c to
|
||||
* @param signer The signer context containing the certificate(s) for the signing key
|
||||
*/
|
||||
private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSignerContext signer) {
|
||||
List<X509Certificate> certificateChain = signer.getCertificateChain();
|
||||
|
||||
if (certificateChain != null && !certificateChain.isEmpty()) {
|
||||
List<String> x5cList = certificateChain.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(cert -> {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(cert.getEncoded());
|
||||
} catch (CertificateEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!x5cList.isEmpty()) {
|
||||
sdJwtCredentialBody.getIssuerSignedJWT().getJwsHeader().setX5c(x5cList);
|
||||
} else {
|
||||
LOGGER.debugf("No valid certificates found in certificate chain for x5c header in SD-JWT credential.");
|
||||
}
|
||||
} else {
|
||||
LOGGER.debugf("No certificate or certificate chain available for x5c header in SD-JWT credential.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,8 +17,10 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@ -75,4 +77,73 @@ public class Proofs {
|
||||
this.attestation = attestation;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the proof type based on which field is populated.
|
||||
* Checks JWT first, then Attestation.
|
||||
*
|
||||
* @return the proof type string (ProofType.JWT or ProofType.ATTESTATION), or null if no proof type is found
|
||||
*/
|
||||
@JsonIgnore
|
||||
public String getProofType() {
|
||||
if (jwt != null && !jwt.isEmpty()) {
|
||||
return ProofType.JWT;
|
||||
} else if (attestation != null && !attestation.isEmpty()) {
|
||||
return ProofType.ATTESTATION;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the proof value based on the proof type.
|
||||
* Sets the appropriate field (JWT or Attestation) depending on the proof type.
|
||||
*
|
||||
* @param proofType the proof type (ProofType.JWT or ProofType.ATTESTATION)
|
||||
* @param proof the proof value to set
|
||||
* @return this instance for method chaining
|
||||
*/
|
||||
public Proofs setProofByType(String proofType, String proof) {
|
||||
if (ProofType.JWT.equals(proofType)) {
|
||||
setJwt(List.of(proof));
|
||||
} else if (ProofType.ATTESTATION.equals(proofType)) {
|
||||
setAttestation(List.of(proof));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all proof values as a list.
|
||||
* Checks JWT proofs first, then Attestation proofs.
|
||||
* Returns an empty list if no proofs are present.
|
||||
*
|
||||
* @return a list containing all proof values, or an empty list if no proofs are present
|
||||
*/
|
||||
@JsonIgnore
|
||||
public List<String> getAllProofs() {
|
||||
List<String> allProofs = new ArrayList<>();
|
||||
if (jwt != null && !jwt.isEmpty()) {
|
||||
allProofs.addAll(jwt);
|
||||
} else if (attestation != null && !attestation.isEmpty()) {
|
||||
allProofs.addAll(attestation);
|
||||
}
|
||||
return allProofs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of proof types that are present (non-null and non-empty).
|
||||
* This can be used to iterate over proof types that need validation.
|
||||
*
|
||||
* @return a list of proof type strings (ProofType.JWT, ProofType.ATTESTATION, etc.) that are present
|
||||
*/
|
||||
@JsonIgnore
|
||||
public List<String> getPresentProofTypes() {
|
||||
List<String> presentTypes = new ArrayList<>();
|
||||
if (jwt != null && !jwt.isEmpty()) {
|
||||
presentTypes.add(ProofType.JWT);
|
||||
}
|
||||
if (attestation != null && !attestation.isEmpty()) {
|
||||
presentTypes.add(ProofType.ATTESTATION);
|
||||
}
|
||||
return presentTypes;
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,6 +244,9 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
|
||||
}
|
||||
}
|
||||
|
||||
// Call hook for post-processing authorization details (e.g., creating state objects)
|
||||
afterAuthorizationDetailsProcessed(userSession, clientSessionCtx, authorizationDetailsResponse);
|
||||
|
||||
return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, s -> {return new TokenResponseContext(formParams, parseResult, clientSessionCtx, s);});
|
||||
}
|
||||
|
||||
|
||||
@ -269,6 +269,22 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
||||
// Default: do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook method called after authorization_details are processed and before the token response is created.
|
||||
* This allows authorization details processors to perform post-processing actions (e.g., creating state objects).
|
||||
* Processors can store information in session notes during processing, and this hook allows them to act on it.
|
||||
* Default implementation does nothing.
|
||||
*
|
||||
* @param userSession the user session
|
||||
* @param clientSessionCtx the client session context
|
||||
* @param authorizationDetailsResponse the processed authorization details response
|
||||
*/
|
||||
protected void afterAuthorizationDetailsProcessed(UserSessionModel userSession, ClientSessionContext clientSessionCtx,
|
||||
List<AuthorizationDetailsResponse> authorizationDetailsResponse) {
|
||||
// Default: do nothing
|
||||
// Subclasses or processors can override/extend this to perform post-processing
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the authorization_details parameter using provider discovery.
|
||||
* This method can be overridden by subclasses to customize the behavior.
|
||||
|
||||
@ -0,0 +1,362 @@
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.constants.OID4VCIConstants;
|
||||
import org.keycloak.crypto.ECDSASignatureSignerContext;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.jose.jwk.JWKBuilder;
|
||||
import org.keycloak.jose.jws.JWSBuilder;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidatorFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel;
|
||||
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
|
||||
import org.keycloak.protocol.oid4vc.model.Proofs;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/**
|
||||
* Test class for verifying Attestation Proof functionality with trusted keys configuration.
|
||||
* Tests both component-based and realm attribute-based configuration.
|
||||
*/
|
||||
public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(OID4VCAttestationProofTest.class);
|
||||
|
||||
@Test
|
||||
public void testAttestationProofWithRealmAttributeTrustedKeys() {
|
||||
String cNonce = getCNonce();
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
runAttestationProofWithRealmAttributeTrustedKeys(session, cNonce);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttestationProofExtractsAttestedKeysFromPayload() {
|
||||
String cNonce = getCNonce();
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
runAttestationProofExtractsAttestedKeysFromPayload(session, cNonce);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttestationProofWithInvalidTrustedKey() {
|
||||
String cNonce = getCNonce();
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
try {
|
||||
runAttestationProofWithInvalidTrustedKey(session, cNonce);
|
||||
fail("Expected VCIssuerException to be thrown");
|
||||
} catch (VCIssuerException e) {
|
||||
assertTrue("Expected error about key not found in trusted key registry but got: " + e.getMessage(),
|
||||
e.getMessage().contains("not found in trusted key registry"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttestationProofValidatorFactoryConfiguration() {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
|
||||
|
||||
assertEquals("Factory ID should be 'attestation'",
|
||||
"attestation", factory.getId());
|
||||
|
||||
ProofValidator validator = factory.create(session);
|
||||
assertNotNull("Factory should create validator", validator);
|
||||
assertEquals("Validator proof type should be 'attestation'",
|
||||
"attestation", validator.getProofType());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttestationProofWithMultipleTrustedKeys() {
|
||||
String cNonce = getCNonce();
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
runAttestationProofWithMultipleTrustedKeys(session, cNonce);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAttestationProof() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String configIdFromScope = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String credConfigId = configIdFromScope != null ? configIdFromScope : scopeName;
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
String cNonce = getCNonce();
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
try {
|
||||
// Configure trusted keys via realm attribute
|
||||
KeyWrapper attestationKey = createECKey("attestationKey");
|
||||
JWK attestationJwk = createJWK(attestationKey);
|
||||
configureTrustedKeysInRealm(session, List.of(attestationJwk));
|
||||
|
||||
// Create attestation JWT
|
||||
KeyWrapper proofKey = createECKey("proofKey");
|
||||
JWK proofJwk = createJWK(proofKey);
|
||||
String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
|
||||
|
||||
// Create credential request with attestation proof
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
|
||||
Proofs proofs = new Proofs().setAttestation(List.of(attestationJwt));
|
||||
|
||||
CredentialRequest request = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setProofs(proofs);
|
||||
|
||||
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(request);
|
||||
|
||||
Response response = endpoint.requestCredential(requestPayload);
|
||||
assertEquals("Response status should be OK", Response.Status.OK.getStatusCode(), response.getStatus());
|
||||
|
||||
CredentialResponse credentialResponse = JsonSerialization.mapper
|
||||
.convertValue(response.getEntity(), CredentialResponse.class);
|
||||
assertNotNull("Credential response should not be null", credentialResponse);
|
||||
assertNotNull("Credentials array should not be null", credentialResponse.getCredentials());
|
||||
assertEquals("Should return 1 credential", 1, credentialResponse.getCredentials().size());
|
||||
|
||||
// Validate the credential
|
||||
Object credentialObj = credentialResponse.getCredentials().get(0).getCredential();
|
||||
assertNotNull("Credential should not be null", credentialObj);
|
||||
assertTrue("Credential should be a string", credentialObj instanceof String);
|
||||
|
||||
String credentialString = (String) credentialObj;
|
||||
JsonWebToken jsonWebToken;
|
||||
try {
|
||||
jsonWebToken = TokenVerifier.create(credentialString, JsonWebToken.class).getToken();
|
||||
} catch (VerificationException e) {
|
||||
fail("Failed to verify JWT credential: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
assertNotNull("A valid credential JWT should be returned", jsonWebToken);
|
||||
assertNotNull("The credentials should include the vc claim", jsonWebToken.getOtherClaims().get("vc"));
|
||||
|
||||
VerifiableCredential vc = JsonSerialization.mapper.convertValue(
|
||||
jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
|
||||
assertNotNull("VerifiableCredential should not be null", vc);
|
||||
assertNotNull("Credential subject should not be null", vc.getCredentialSubject());
|
||||
|
||||
LOGGER.infof("Successfully issued credential with attestation proof");
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Test failed with exception", e);
|
||||
fail("Test should not throw exception: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures an EC key with the given key ID.
|
||||
*/
|
||||
private static KeyWrapper createECKey(String keyId) {
|
||||
KeyWrapper key = getECKey(keyId);
|
||||
key.setKid(keyId);
|
||||
key.setAlgorithm("ES256");
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JWK from a KeyWrapper.
|
||||
*/
|
||||
private static JWK createJWK(KeyWrapper keyWrapper) {
|
||||
JWK jwk = JWKBuilder.create().ec(keyWrapper.getPublicKey());
|
||||
jwk.setKeyId(keyWrapper.getKid());
|
||||
jwk.setAlgorithm(keyWrapper.getAlgorithm());
|
||||
return jwk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures trusted keys in realm attribute.
|
||||
*/
|
||||
private static void configureTrustedKeysInRealm(KeycloakSession session, List<JWK> trustedKeys) throws IOException {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
String trustedKeysJson = JsonSerialization.writeValueAsString(trustedKeys);
|
||||
realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, trustedKeysJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VCIssuanceContext with an attestation proof.
|
||||
*/
|
||||
private static VCIssuanceContext createVCIssuanceContextWithAttestationProof(KeycloakSession session, String attestationJwt) {
|
||||
VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
|
||||
vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
|
||||
return vcIssuanceContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates proof and returns attested keys, with common assertions.
|
||||
*/
|
||||
private static List<JWK> validateProofAndAssert(AttestationProofValidator validator,
|
||||
VCIssuanceContext vcIssuanceContext,
|
||||
KeyWrapper expectedProofKey) {
|
||||
List<JWK> attestedKeys = validator.validateProof(vcIssuanceContext);
|
||||
assertNotNull("Attested keys should not be null", attestedKeys);
|
||||
assertEquals("Should contain exactly one attested key", 1, attestedKeys.size());
|
||||
assertEquals("Attested key ID should match proof key ID",
|
||||
expectedProofKey.getKid(), attestedKeys.get(0).getKeyId());
|
||||
return attestedKeys;
|
||||
}
|
||||
|
||||
private static void runAttestationProofWithRealmAttributeTrustedKeys(KeycloakSession session, String cNonce) {
|
||||
try {
|
||||
KeyWrapper attestationKey = createECKey("attestationKey");
|
||||
KeyWrapper proofKey = createECKey("proofKey");
|
||||
|
||||
// Create attestation JWT
|
||||
JWK proofJwk = createJWK(proofKey);
|
||||
String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
|
||||
|
||||
// Configure trusted keys via realm attribute
|
||||
JWK attestationJwk = createJWK(attestationKey);
|
||||
configureTrustedKeysInRealm(session, List.of(attestationJwk));
|
||||
|
||||
// Create VCIssuanceContext with attestation proof
|
||||
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
|
||||
|
||||
// Create validator using factory (should load from realm attribute)
|
||||
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
|
||||
AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
|
||||
|
||||
// Validate proof
|
||||
validateProofAndAssert(validator, vcIssuanceContext, proofKey);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Test failed with exception", e);
|
||||
fail("Test should not throw exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the validator correctly extracts attested_keys from the attestation payload
|
||||
* after verifying the attestation JWT signature with a trusted key.
|
||||
*/
|
||||
private static void runAttestationProofExtractsAttestedKeysFromPayload(KeycloakSession session, String cNonce) {
|
||||
try {
|
||||
KeyWrapper attestationKey = createECKey("attestationKey");
|
||||
KeyWrapper proofKey = createECKey("proofKey");
|
||||
|
||||
// Create attestation JWT with attested_keys in payload
|
||||
JWK proofJwk = createJWK(proofKey);
|
||||
|
||||
KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
|
||||
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
|
||||
payload.setNonce(cNonce);
|
||||
payload.setAttestedKeys(List.of(proofJwk));
|
||||
payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue()));
|
||||
payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue()));
|
||||
|
||||
String attestationJwt = new JWSBuilder()
|
||||
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
|
||||
.kid(attestationKey.getKid())
|
||||
.jsonContent(payload)
|
||||
.sign(new ECDSASignatureSignerContext(attestationKey));
|
||||
|
||||
// Configure trusted key for verifying the attestation signature
|
||||
// According to spec, the signature must verify with a trusted key from the header
|
||||
JWK attestationJwk = createJWK(attestationKey);
|
||||
configureTrustedKeysInRealm(session, List.of(attestationJwk));
|
||||
|
||||
// Create VCIssuanceContext with attestation proof
|
||||
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
|
||||
|
||||
// Create validator with trusted keys configured
|
||||
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
|
||||
AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
|
||||
|
||||
// Validate proof - should verify signature with trusted key and extract attested_keys from payload
|
||||
validateProofAndAssert(validator, vcIssuanceContext, proofKey);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Test failed with exception", e);
|
||||
fail("Test should not throw exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void runAttestationProofWithInvalidTrustedKey(KeycloakSession session, String cNonce) throws IOException {
|
||||
KeyWrapper attestationKey = createECKey("attestationKey");
|
||||
KeyWrapper proofKey = createECKey("proofKey");
|
||||
KeyWrapper unrelatedKey = createECKey("unrelatedKey");
|
||||
|
||||
// Create attestation JWT
|
||||
JWK proofJwk = createJWK(proofKey);
|
||||
String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
|
||||
|
||||
// Configure trusted keys with wrong key (unrelatedKey instead of attestationKey)
|
||||
JWK unrelatedJwk = createJWK(unrelatedKey);
|
||||
configureTrustedKeysInRealm(session, List.of(unrelatedJwk));
|
||||
|
||||
try {
|
||||
// Create VCIssuanceContext with attestation proof
|
||||
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
|
||||
|
||||
// Create validator using factory
|
||||
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
|
||||
AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
|
||||
|
||||
// Validate proof - should fail because trusted key doesn't match
|
||||
validator.validateProof(vcIssuanceContext);
|
||||
} catch (VCIssuerException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
fail("Unexpected exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void runAttestationProofWithMultipleTrustedKeys(KeycloakSession session, String cNonce) {
|
||||
try {
|
||||
KeyWrapper attestationKey1 = createECKey("attestationKey1");
|
||||
KeyWrapper attestationKey2 = createECKey("attestationKey2");
|
||||
KeyWrapper proofKey = createECKey("proofKey");
|
||||
|
||||
// Create attestation JWT with first attestation key
|
||||
JWK proofJwk = createJWK(proofKey);
|
||||
String attestationJwt = createValidAttestationJwt(session, attestationKey1, proofJwk, cNonce);
|
||||
|
||||
// Configure multiple trusted keys via realm attribute
|
||||
JWK attestationJwk1 = createJWK(attestationKey1);
|
||||
JWK attestationJwk2 = createJWK(attestationKey2);
|
||||
configureTrustedKeysInRealm(session, List.of(attestationJwk1, attestationJwk2));
|
||||
|
||||
// Create VCIssuanceContext with attestation proof
|
||||
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
|
||||
|
||||
// Create validator using factory
|
||||
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
|
||||
AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
|
||||
|
||||
// Validate proof - should succeed because attestationKey1 is in trusted keys
|
||||
validateProofAndAssert(validator, vcIssuanceContext, proofKey);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Test failed with exception", e);
|
||||
fail("Test should not throw exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,7 +118,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1";
|
||||
protected static final URI TEST_DID = URI.create("did:web:test.org");
|
||||
protected static final List<String> TEST_TYPES = List.of("VerifiableCredential");
|
||||
protected static final Instant TEST_EXPIRATION_DATE = Instant.ofEpochSecond(2000);
|
||||
protected static final Instant TEST_EXPIRATION_DATE = Instant.now().plus(365, ChronoUnit.DAYS).truncatedTo(ChronoUnit.SECONDS);
|
||||
protected static final Instant TEST_ISSUANCE_DATE = Instant.ofEpochSecond(1000);
|
||||
|
||||
protected static final KeyWrapper RSA_KEY = getRsaKey();
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
@ -34,6 +35,7 @@ import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.crypto.ServerECDSASignatureVerifierContext;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.jose.jws.crypto.HashUtils;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.LDCredentialBody;
|
||||
@ -58,6 +60,8 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
|
||||
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
@ -203,6 +207,69 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
|
||||
List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSdJwtCredentialContainsX5cHeader() {
|
||||
getTestingClient()
|
||||
.server(TEST_REALM_NAME)
|
||||
.run(session -> {
|
||||
String signingKeyId = getKeyIdFromSession(session);
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
.setCredentialIssuer(TEST_DID.toString())
|
||||
.setCredentialType("https://credentials.example.com/test-credential")
|
||||
.setTokenJwsType("example+sd-jwt")
|
||||
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
|
||||
.setNumberOfDecoys(0)
|
||||
.setSdJwtVisibleClaims(List.of())
|
||||
.setSigningKeyId(signingKeyId)
|
||||
.setSigningAlgorithm(Algorithm.RS256);
|
||||
|
||||
SdJwtCredentialSigner sdJwtCredentialSigner = new SdJwtCredentialSigner(session);
|
||||
|
||||
VerifiableCredential testCredential = getTestCredential(Map.of());
|
||||
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder()
|
||||
.buildCredentialBody(testCredential, credentialBuildConfig);
|
||||
|
||||
String sdJwt = sdJwtCredentialSigner.signCredential(sdJwtCredentialBody, credentialBuildConfig);
|
||||
|
||||
String[] splittedSdToken = sdJwt.split(SDJWT_DELIMITER);
|
||||
String[] splittedToken = splittedSdToken[0].split("\\.");
|
||||
|
||||
String jwt = new StringJoiner(".")
|
||||
.add(splittedToken[0])
|
||||
.add(splittedToken[1])
|
||||
.add(splittedToken[2])
|
||||
.toString();
|
||||
|
||||
KeyWrapper keyWrapper = getKeyFromSession(session);
|
||||
SignatureVerifierContext verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper);
|
||||
|
||||
TokenVerifier<JsonWebToken> verifier = TokenVerifier
|
||||
.create(jwt, JsonWebToken.class)
|
||||
.verifierContext(verifierContext);
|
||||
verifier.publicKey((PublicKey) keyWrapper.getPublicKey());
|
||||
|
||||
try {
|
||||
verifier.verify();
|
||||
|
||||
JWSHeader header = verifier.getHeader();
|
||||
assertNotNull("x5c header should be present in SD-JWT credential", header.getX5c());
|
||||
assertFalse("x5c header should contain at least one certificate", header.getX5c().isEmpty());
|
||||
|
||||
if (keyWrapper.getCertificate() != null) {
|
||||
try {
|
||||
String expectedCert = Base64.getEncoder().encodeToString(keyWrapper.getCertificate().getEncoded());
|
||||
assertEquals("First certificate in x5c should match the signing key certificate",
|
||||
expectedCert, header.getX5c().get(0));
|
||||
} catch (CertificateEncodingException e) {
|
||||
fail("Failed to encode certificate for comparison: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (VerificationException e) {
|
||||
fail("The credential should successfully be verified: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String
|
||||
algorithm, Map<String, Object> claims, int decoys, List<String> visibleClaims) {
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user