Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.services.CorsErrorResponseException;
Expand Down Expand Up @@ -998,19 +999,22 @@ public Response requestCredential(String requestPayload) {
OID4VCIssuerWellKnownProvider.toSupportedCredentialConfiguration(session, authorizedCredentialScope);

enforceProofContractForCredential(supportedCredential, credentialRequest.getProofs());
Map<String, JsonWebToken> verifiedProofCNonces = new HashMap<>();

// Get the list of all proofs (handles single proof, multiple proofs, or none)
List<String> allProofs = getAllProofs(credentialRequest);
if (allProofs.stream().anyMatch(p -> p == null || p.isBlank()))
if (allProofs.stream().anyMatch(p -> p == null || p.isBlank())) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_PROOF, "Proof values must not be null or blank"));
}

// Generate credential response
CredentialResponse responseVO = new CredentialResponse();
// Prepare credential bodies and validate key binding proofs before consuming the nonce.
List<VCIssuanceContext> vcIssuanceContexts = new ArrayList<>();

if (allProofs.isEmpty()) {
// Single issuance without proof
Object theCredential = getCredential(authResult, supportedCredential, tokenAuthDetail, credentialRequest, eventBuilder);
responseVO.addCredential(theCredential);
VCIssuanceContext vcIssuanceContext = prepareCredential(authResult, supportedCredential, tokenAuthDetail, credentialRequest, eventBuilder);
vcIssuanceContexts.add(vcIssuanceContext);
verifiedProofCNonces.putAll(vcIssuanceContext.getVerifiedCNonces());
} else {
// Issue credentials for each proof
Proofs originalProofs = credentialRequest.getProofs();
Expand All @@ -1027,14 +1031,28 @@ public Response requestCredential(String requestPayload) {
}
}

for (String currentProof : allProofs) {
Proofs proofForIteration = Proofs.create(proofType, currentProof);
// Creating credential with keybinding to the current proof
credentialRequest.setProofs(proofForIteration);
Object theCredential = getCredential(authResult, supportedCredential, tokenAuthDetail, credentialRequest, eventBuilder);
responseVO.addCredential(theCredential);
try {
for (String currentProof : allProofs) {
Proofs proofForIteration = Proofs.create(proofType, currentProof);
// Creating credential with keybinding to the current proof
credentialRequest.setProofs(proofForIteration);
VCIssuanceContext vcIssuanceContext = prepareCredential(authResult, supportedCredential, tokenAuthDetail, credentialRequest, eventBuilder);
vcIssuanceContexts.add(vcIssuanceContext);
verifiedProofCNonces.putAll(vcIssuanceContext.getVerifiedCNonces());
}
} finally {
credentialRequest.setProofs(originalProofs);
}
credentialRequest.setProofs(originalProofs);
}

// Fail-fast if any cnonce already consumed without consuming it
rejectConsumedProofCNonce(verifiedProofCNonces);

// Generate credential response before consuming the nonce, so server-side signing/encryption failures do not
// burn an otherwise valid proof nonce.
CredentialResponse responseVO = new CredentialResponse();
for (VCIssuanceContext vcIssuanceContext : vcIssuanceContexts) {
responseVO.addCredential(signCredential(supportedCredential, vcIssuanceContext, eventBuilder));
Comment thread
forkimenjeckayang marked this conversation as resolved.
}

// Encrypt all responses if encryption parameters are provided, except for error credential responses
Expand All @@ -1053,6 +1071,9 @@ public Response requestCredential(String requestPayload) {
.build();
}

// Consume cnonce to prevent replay
consumeProofCNonce(verifiedProofCNonces);

// Mark event as successful
eventBuilder.detail(Details.SCOPE, supportedCredential.getScope())
.detail(Details.VERIFIABLE_CREDENTIAL_FORMAT, supportedCredential.getFormat())
Expand Down Expand Up @@ -1349,6 +1370,52 @@ private List<String> getAllProofs(CredentialRequest credentialRequestVO) {
return proofs.getAllProofs();
}

private CNonceHandler getCNonceHandlerForProofValidation() {
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);

if (cNonceHandler == null) {
throw new ErrorResponseException(INVALID_PROOF.getValue(), "CNonce handler not configured", Response.Status.BAD_REQUEST);
}

if (!cNonceHandler.supportsCNonceConsumption()) {
throw new ErrorResponseException(INVALID_PROOF.getValue(), "CNonce handler does not support nonce consumption", Response.Status.BAD_REQUEST);
}

return cNonceHandler;
}

private void rejectConsumedProofCNonce(Map<String, JsonWebToken> verifiedCNonces) {
if (verifiedCNonces.isEmpty()) {
return;
}

CNonceHandler cNonceHandler = getCNonceHandlerForProofValidation();

for (Map.Entry<String, JsonWebToken> cNonce : verifiedCNonces.entrySet()) {
try {
cNonceHandler.ensureCNonceNotYetConsumed(cNonce.getKey());
} catch (VerificationException e) {
throw new ErrorResponseException(INVALID_NONCE.getValue(), e.getMessage(), Response.Status.BAD_REQUEST);
}
}
}

private void consumeProofCNonce(Map<String, JsonWebToken> verifiedCNonces) {
if (verifiedCNonces.isEmpty()) {
return;
}

CNonceHandler cNonceHandler = getCNonceHandlerForProofValidation();

for (Map.Entry<String, JsonWebToken> cNonce : verifiedCNonces.entrySet()) {
try {
cNonceHandler.consumeCNonce(cNonce.getKey(), cNonce.getValue());
} catch (VerificationException e) {
throw new ErrorResponseException(INVALID_NONCE.getValue(), e.getMessage(), Response.Status.BAD_REQUEST);
}
Comment thread
forkimenjeckayang marked this conversation as resolved.
}
}

private void enforceProofContractForCredential(SupportedCredentialConfiguration credentialConfiguration, Proofs proofs) {
boolean proofConfigured = credentialConfiguration != null
&& credentialConfiguration.getProofTypesSupported() != null
Expand Down Expand Up @@ -1605,20 +1672,13 @@ private AuthenticationManager.AuthResult getAuthResult() {
}

/**
* Get a signed credential
*
* @param authResult authResult containing the userSession to create the credential for
* @param credentialConfig the supported credential configuration
* @param authDetail Parsed OID4VC authorization_detail
* @param credentialRequestVO the credential request
* @param eventBuilder the event builder for logging events
* @return the signed credential
* Prepare an unsigned credential and validate any key binding proof before signing.
*/
private Object getCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig,
OID4VCAuthorizationDetail authDetail,
CredentialRequest credentialRequestVO,
EventBuilder eventBuilder
private VCIssuanceContext prepareCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig,
OID4VCAuthorizationDetail authDetail,
CredentialRequest credentialRequestVO,
EventBuilder eventBuilder
) {

// Get the client scope model from the credential configuration
Expand All @@ -1645,6 +1705,12 @@ private Object getCredential(AuthenticationManager.AuthResult authResult,
// Enforce key binding prior to signing if necessary
enforceKeyBindingIfProofProvided(vcIssuanceContext);

return vcIssuanceContext;
}

private Object signCredential(SupportedCredentialConfiguration credentialConfig,
VCIssuanceContext vcIssuanceContext,
EventBuilder eventBuilder) {
// Retrieve matching credential signer
CredentialSigner<?> credentialSigner = session.getProvider(CredentialSigner.class,
credentialConfig.getFormat());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
*/
package org.keycloak.protocol.oid4vc.issuance;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.keycloak.jose.jwk.JWK;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AuthenticationManager;

/**
Expand All @@ -42,6 +46,8 @@ public class VCIssuanceContext {

private List<JWK> attestedKeys;

private Map<String, JsonWebToken> verifiedCNonces = new LinkedHashMap<>();


public CredentialBody getCredentialBody() {
return credentialBody;
Expand Down Expand Up @@ -83,4 +89,13 @@ public VCIssuanceContext setAttestedKeys(List<JWK> attestedKeys) {
this.attestedKeys = attestedKeys;
return this;
}

public Map<String, JsonWebToken> getVerifiedCNonces() {
return Collections.unmodifiableMap(verifiedCNonces);
}

public VCIssuanceContext addVerifiedCNonce(String cNonce, JsonWebToken cNonceToken) {
verifiedCNonces.put(cNonce, cNonceToken);
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@
import java.util.List;
import java.util.Optional;

import org.keycloak.common.util.Time;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.ErrorType;
Expand All @@ -35,10 +30,6 @@
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;

import org.apache.commons.codec.binary.Hex;

import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_NONCE;

/**
* Validates attestation proofs as per OID4VCI specification.
*
Expand Down Expand Up @@ -77,19 +68,6 @@ public List<JWK> validateProof(VCIssuanceContext vcIssuanceContext) throws VCIss
throw new VCIssuerException(ErrorType.INVALID_PROOF, "No valid attested keys found in attestation proof");
}

// Nonce replay protection
//
String nonce = attestationBody.getNonce();
if (nonce != null) {
RealmModel realmModel = keycloakSession.getContext().getRealm();
SingleUseObjectProvider singleUseCache = keycloakSession.singleUseObjects();
String hashString = Hex.encodeHexString(HashUtils.hash("SHA1", nonce.getBytes()));
Long nonceLifetimeSeconds = realmModel.getAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, 60L);
if (!singleUseCache.putIfAbsent(hashString, Time.currentTime() + 10 * nonceLifetimeSeconds)) {
throw new VCIssuerException(INVALID_NONCE, "Nonce in proof has already been used");
}
}

return attestationBody.getAttestedKeys();
} catch (VCIssuerException e) {
throw e; // Re-throw specific exceptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,24 +196,29 @@ private static void validateAttestationPayload(

KeycloakContext keycloakContext = keycloakSession.getContext();
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
if (cNonceHandler == null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "CNonce handler not configured");
}
if (!cNonceHandler.supportsCNonceTokenRetrieval()) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "CNonce handler does not support token retrieval");
}
Comment thread
forkimenjeckayang marked this conversation as resolved.

if (attestationBody.getNonce() == null) {
String nonce = attestationBody.getNonce();
if (nonce == null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing 'nonce' in attestation");
}

// Validate nonce if present. If provided, it must correspond to a nonce value provided by Keycloak.
if (attestationBody.getNonce() != null) {
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);
}
try {
vcIssuanceContext.addVerifiedCNonce(
nonce,
cNonceHandler.verifyCNonceAndGetToken(
nonce,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import org.keycloak.common.VerificationException;
import org.keycloak.provider.Provider;
import org.keycloak.representations.JsonWebToken;

/**
* @author Pascal Knüppel
Expand All @@ -49,9 +50,66 @@ public interface CNonceHandler extends Provider {
* @param audiences the expected audiences for jwt-based cNonces
* @param additionalDetails additional attributes that might be required to build the cNonce and that are handler
* specific
* @throws VerificationException if the cNonce cannot be verified
*/
public void verifyCNonce(String cNonce, List<String> audiences, @Nullable Map<String, Object> additionalDetails) throws VerificationException;

/**
* Verifies the validity of a cNonce value and returns its verified token representation.
*
* @param cNonce the cNonce to validate
* @param audiences the expected audiences for jwt-based cNonces
* @param additionalDetails additional attributes that might be required to build the cNonce and that are handler
* specific
* @return the verified cNonce token representation
* @throws VerificationException if the cNonce cannot be verified
*/
public default JsonWebToken verifyCNonceAndGetToken(String cNonce, List<String> audiences, @Nullable Map<String, Object> additionalDetails)
throws VerificationException {
throw new VerificationException("c_nonce token retrieval is not supported");
}

/**
* @return {@code true} if this handler can return the verified cNonce token representation.
*/
public default boolean supportsCNonceTokenRetrieval() {
return false;
}

/**
* Marks a verified cNonce value as consumed.
*
* @param cNonce the cNonce to consume
*/
public default void consumeCNonce(String cNonce) throws VerificationException {
throw new VerificationException("c_nonce consumption is not supported");
}

/**
* Marks a verified cNonce value as consumed.
*
* @param cNonce the cNonce to consume
* @param cNonceToken the already verified cNonce token
*/
public default void consumeCNonce(String cNonce, JsonWebToken cNonceToken) throws VerificationException {
consumeCNonce(cNonce);
}

/**
* @return {@code true} if this handler can mark cNonce values as consumed.
*/
public default boolean supportsCNonceConsumption() {
return false;
}

/**
* Checks if {@code cNonce} is so far not yet consumed (without consuming it) for fail-fast purposes.
* @throws VerificationException if already consumed or check not supported.
*/
default void ensureCNonceNotYetConsumed(String cNonce) throws VerificationException {
throw new VerificationException("c_nonce consumption check is not supported");
}

@Override
default void close() {
// do nothing
Expand Down
Loading
Loading