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
8 changes: 5 additions & 3 deletions services/src/main/java/org/keycloak/crypto/CryptoUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.util.Strings;

/**
* Utility class for common cryptographic operations and algorithm discovery.
Expand Down Expand Up @@ -59,10 +60,10 @@ public static List<String> getSupportedAsymmetricSignatureAlgorithms(KeycloakSes
* for those that use asymmetric algorithms (RSA, EC, EdDSA, etc.).
*
* @param session The Keycloak session
* @return List of asymmetric signature algorithm names
* @return List of asymmetric encryption algorithm names
*/
public static List<String> getSupportedAsymmetricEncryptionAlgorithms(KeycloakSession session) {
return session.keys()
List<String> encAlgos = session.keys()
.getKeysStream(session.getContext().getRealm())
.filter(key -> KeyUse.ENC.equals(key.getUse()))
.filter(key -> {
Expand All @@ -71,8 +72,9 @@ public static List<String> getSupportedAsymmetricEncryptionAlgorithms(KeycloakSe
return k instanceof PublicKey || key.getPrivateKey() instanceof PrivateKey;
})
.map(KeyWrapper::getAlgorithm)
.filter(algorithm -> algorithm != null && !algorithm.isEmpty())
.filter(alg -> !Strings.isEmpty(alg))
.distinct()
.toList();
return encAlgos;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -720,48 +720,32 @@ public Response requestCredential(String requestPayload) {
.map(CredentialResponseEncryptionMetadata::getEncryptionRequired)
.orElse(false);

// Check if encryption is required but not provided
if (isEncryptionRequired && encryptionParams == null) {
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}

// Validate encryption parameters if provided
if (encryptionParams != null) {
validateEncryptionParameters(encryptionParams);
if (isEncryptionRequired || encryptionParams != null) {

// Select and validate alg
String selectedAlg = selectKeyManagementAlg(encryptionMetadata, encryptionParams.getJwk());
if (selectedAlg == null) {
String errorMessage = String.format("No supported key management algorithm (alg) for provided JWK (kty=%s)",
encryptionParams.getJwk().getKeyType());
// Check if encryption is required but not provided
if (encryptionParams == null) {
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}

validateEncryptionParameters(encryptionParams);

// Check if enc is supported
if (!encryptionMetadata.getEncValuesSupported().contains(encryptionParams.getEnc())) {
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
encryptionParams.getEnc());
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s", encryptionParams.getEnc());
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}

// Check compression (unchanged)
if (encryptionParams.getZip() != null &&
!isSupportedCompression(encryptionMetadata, encryptionParams.getZip())) {
String errorMessage = String.format("Unsupported compression parameter: zip=%s",
encryptionParams.getZip());
if (encryptionParams.getZip() != null && !isSupportedCompression(encryptionMetadata, encryptionParams.getZip())) {
String errorMessage = String.format("Unsupported compression parameter: zip=%s", encryptionParams.getZip());
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
Expand Down Expand Up @@ -958,7 +942,7 @@ public Response requestCredential(String requestPayload) {
// Encrypt all responses if encryption parameters are provided, except for error credential responses
Response response;
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
String jwe = encryptCredentialResponse(eventBuilder, responseVO, encryptionParams, encryptionMetadata);
response = Response.ok()
.type(MediaType.APPLICATION_JWT)
.entity(jwe)
Expand Down Expand Up @@ -1203,30 +1187,30 @@ else if (singleProof instanceof JwtProof jwtProof) {
validateProofTypes(credentialRequest.getProofs());
}

private String selectKeyManagementAlg(CredentialResponseEncryptionMetadata metadata, JWK jwk) {
private String selectKeyManagementAlg(EventBuilder eventBuilder, CredentialResponseEncryptionMetadata metadata, JWK jwk) {

List<String> supportedAlgs = metadata.getAlgValuesSupported();
if (supportedAlgs == null || supportedAlgs.isEmpty()) {
LOGGER.warn("No supported encryption algorithms");
return null;
}

// The alg parameter MUST be present in the JWK
String jwkAlg = jwk.getAlgorithm();
if (jwkAlg == null) {
// If alg is missing from JWK, this is invalid
LOGGER.debugf("JWK is missing required 'alg' parameter for key type: %s", jwk.getKeyType());
LOGGER.warnf("JWK is missing required 'alg' parameter for key type: %s", jwk.getKeyType());
return null;
}

// Verify the alg is supported by the server
if (supportedAlgs.contains(jwkAlg)) {
return jwkAlg;
if (!supportedAlgs.contains(jwkAlg)) {
String errorMessage = String.format("JWK algorithm '%s' is not supported. Supported algorithms: %s", jwkAlg, supportedAlgs);
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}

// If the JWK's alg is not supported, we cannot proceed
LOGGER.debugf("JWK algorithm '%s' is not supported by the server. Supported algorithms: %s",
jwkAlg, supportedAlgs);
throw new IllegalArgumentException(String.format("JWK algorithm '%s' is not supported. Supported algorithms: %s", jwkAlg, supportedAlgs));

return jwkAlg;
}

private List<String> getAllProofs(CredentialRequest credentialRequestVO) {
Expand Down Expand Up @@ -1281,13 +1265,13 @@ private boolean hasProofEntries(List<String> proofs) {
/**
* Encrypts a CredentialResponse as a JWE using the provided encryption parameters.
*
* @param response The CredentialResponse to encrypt
* @param response The CredentialResponse to encrypt
* @param encryptionParams The encryption parameters (alg, enc, jwk)
* @return The compact JWE serialization
* @throws BadRequestException If encryption parameters are invalid
* @throws BadRequestException If encryption parameters are invalid
* @throws WebApplicationException If encryption fails due to server issues
*/
private String encryptCredentialResponse(CredentialResponse response,
private String encryptCredentialResponse(EventBuilder eventBuilder, CredentialResponse response,
CredentialResponseEncryption encryptionParams,
CredentialResponseEncryptionMetadata metadata) {
validateEncryptionParameters(encryptionParams);
Expand All @@ -1298,21 +1282,22 @@ private String encryptCredentialResponse(CredentialResponse response,

// Parse public key
PublicKey publicKey;
try {
publicKey = JWKParser.create(jwk).toPublicKey();
if (publicKey == null) {
LOGGER.debug("Invalid JWK: Failed to parse public key");
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS,
"Invalid JWK: Failed to parse public key."));
{
String message = "Invalid JWK: Failed to parse public key";
try {
publicKey = JWKParser.create(jwk).toPublicKey();
if (publicKey == null) {
LOGGER.debug(message);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, message));
}
} catch (Exception e) {
LOGGER.debug(message);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, message));
}
} catch (Exception e) {
LOGGER.debugf("Failed to parse JWK: %s", e.getMessage());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS,
"Invalid JWK: Failed to parse public key."));
}

// Select alg
String selectedAlg = selectKeyManagementAlg(metadata, jwk);
String selectedAlg = selectKeyManagementAlg(eventBuilder, metadata, jwk);

// Perform encryption
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.crypto.KeyType.RSA;
import static org.keycloak.jose.jwk.RSAPublicJWK.RS256;

/**
Expand Down Expand Up @@ -379,21 +378,10 @@ public static CredentialRequestEncryptionMetadata getCredentialRequestEncryption
* Returns the supported encryption algorithms from realm attributes.
*/
public static List<String> getSupportedEncryptionAlgorithms(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
KeyManager keyManager = session.keys();

List<String> supportedEncryptionAlgorithms = CryptoUtils.getSupportedAsymmetricEncryptionAlgorithms(session);

// Default algorithms if none configured
if (supportedEncryptionAlgorithms.isEmpty()) {
boolean hasRsaKeys = keyManager.getKeysStream(realm)
.filter(key -> KeyUse.ENC.equals(key.getUse()))
.anyMatch(key -> RSA.equals(key.getType()));

if (hasRsaKeys) {
supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP);
supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP_256);
}
supportedEncryptionAlgorithms = List.of(JWEConstants.RSA_OAEP, JWEConstants.RSA_OAEP_256);
}

return supportedEncryptionAlgorithms;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

/**
* Represents the credential_response_encryption metadata for an OID4VCI Credential Issuer.
* @see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-16.html#name-credential-issuer-metadata-p
* @see <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p"></a>
*
* @author <a href="mailto:[email protected]">Bertrand Ogen</a>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,9 @@ public Object getConfig() {
config.setAuthorizationDetailsTypesSupported(authorizationDetailsTypesSupported);
}

// HAIP-1.0 does not want to see this property (don't set to false)
if (Profile.isFeatureEnabled(Profile.Feature.CIMD)) {
config.setClientIdMetadataDocumentSupported(true);
} else {
config.setClientIdMetadataDocumentSupported(false);
}

config = checkConfigOverride(config);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
import org.keycloak.events.EventType;
import org.keycloak.keys.KeyProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsParser;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
Expand Down Expand Up @@ -94,6 +93,10 @@
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES;
import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;

/**
Expand Down Expand Up @@ -447,10 +450,9 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH, OID4VCConstants.KeyAttestationResistanceLevels.MODERATE)
);
Map<String, String> sdJwtAttrs = Optional.ofNullable(sdJwtScope.getAttributes()).orElseGet(HashMap::new);
sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
sdJwtAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
sdJwtAttrs.put(VC_BINDING_REQUIRED, "true");
sdJwtAttrs.put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
sdJwtAttrs.put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
sdJwtScope.setAttributes(sdJwtAttrs);
realm.addClientScope(sdJwtScope);

Expand All @@ -465,10 +467,9 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
Collections.emptyList()
);
Map<String, String> jwtVcAttrs = Optional.ofNullable(jwtVcScope.getAttributes()).orElseGet(HashMap::new);
jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt,attestation");
jwtVcAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
jwtVcAttrs.put(VC_BINDING_REQUIRED, "true");
jwtVcAttrs.put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt,attestation");
jwtVcAttrs.put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
jwtVcScope.setAttributes(jwtVcAttrs);
realm.addClientScope(jwtVcScope);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ public void testRequestCredentialWithCredentialConfigurationId() {
.credentialConfigurationId(authDetails.get(0).getCredentialConfigurationId())
.bearerToken(tokenResponse.getAccessToken())
.send().getCredentialResponse());
assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), "Unexpected - " + ex.getMessage());
assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), ex.getMessage());
}

@Test
Expand Down
Loading