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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
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 jakarta.ws.rs.BadRequestException;
Expand Down Expand Up @@ -65,6 +67,8 @@
import org.keycloak.jose.jwe.JWEHeader;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
Expand Down Expand Up @@ -102,6 +106,7 @@
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.JwtProof;
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.OfferResponseType;
Expand Down Expand Up @@ -996,31 +1001,45 @@ public Response requestCredential(String requestPayload) {
OID4VCIssuerWellKnownProvider.toSupportedCredentialConfiguration(session, authorizedCredentialScope);

enforceProofContractForCredential(supportedCredential, credentialRequest.getProofs());
Set<String> proofCNonces = new LinkedHashSet<>();

// Get the list of all proofs (handles single proof, multiple proofs, or none)
List<String> allProofs = getAllProofs(credentialRequest);

// 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);
proofCNonces.addAll(extractProofCNonce(credentialRequest.getProofs()));
} else {
// Issue credentials for each proof
Proofs originalProofs = credentialRequest.getProofs();
// Determine the proof type from the original proofs
String proofType = originalProofs.getProofType();

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);
proofCNonces.addAll(extractProofCNonce(proofForIteration));
}
} finally {
credentialRequest.setProofs(originalProofs);
}
credentialRequest.setProofs(originalProofs);
}

consumeProofCNonce(proofCNonces);

// Generate credential response after the nonce was consumed.
CredentialResponse responseVO = new CredentialResponse();
for (VCIssuanceContext vcIssuanceContext : vcIssuanceContexts) {
responseVO.addCredential(signCredential(supportedCredential, vcIssuanceContext, eventBuilder));
}

// Encrypt all responses if encryption parameters are provided, except for error credential responses
Expand Down Expand Up @@ -1335,6 +1354,63 @@ private List<String> getAllProofs(CredentialRequest credentialRequestVO) {
return proofs.getAllProofs();
}

private void consumeProofCNonce(Set<String> cNonces) {
if (cNonces.isEmpty()) {
return;
}

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

for (String cNonce : cNonces) {
try {
cNonceHandler.consumeCNonce(cNonce);
} catch (VerificationException e) {
throw new ErrorResponseException(INVALID_NONCE.getValue(), e.getMessage(), Response.Status.BAD_REQUEST);
}
}
}

private Set<String> extractProofCNonce(Proofs proofs) {
Set<String> cNonces = new LinkedHashSet<>();
if (proofs == null) {
return cNonces;
}

// Best-effort nonce extraction: skip proofs that cannot be parsed.
// Proof validation happens later in prepareCredential / enforceKeyBindingIfProofProvided,
// which will reject malformed proofs with the appropriate error.
if (proofs.getJwt() != null) {
for (String jwtProof : proofs.getJwt()) {
try {
JWSInput jwsInput = new JWSInput(jwtProof);
AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class);
if (!Strings.isEmpty(proofPayload.getNonce())) {
cNonces.add(proofPayload.getNonce());
}
} catch (JWSInputException | IOException e) {
LOGGER.debugf("Skipping nonce extraction for unparseable JWT proof: %s", e.getMessage());
}
}
}
if (proofs.getAttestation() != null) {
for (String attestationProof : proofs.getAttestation()) {
try {
KeyAttestationJwtBody attestationBody = new JWSInput(attestationProof)
.readJsonContent(KeyAttestationJwtBody.class);
if (!Strings.isEmpty(attestationBody.getNonce())) {
cNonces.add(attestationBody.getNonce());
}
} catch (JWSInputException e) {
LOGGER.debugf("Skipping nonce extraction for unparseable attestation proof: %s", e.getMessage());
}
}
}
return cNonces;
}

private void enforceProofContractForCredential(SupportedCredentialConfiguration credentialConfiguration, Proofs proofs) {
boolean proofConfigured = credentialConfiguration != null
&& credentialConfiguration.getProofTypesSupported() != null
Expand Down Expand Up @@ -1591,20 +1667,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 @@ -1631,6 +1700,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 @@ -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 @@ -52,6 +52,15 @@ public interface CNonceHandler extends Provider {
*/
public void verifyCNonce(String cNonce, List<String> audiences, @Nullable Map<String, Object> additionalDetails) throws VerificationException;

/**
* 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");
}

@Override
default void close() {
// do nothing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package org.keycloak.protocol.oid4vc.issuance.keybinding;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
Expand All @@ -41,9 +42,11 @@
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.model.JwtCNonce;
import org.keycloak.representations.JsonWebToken;
Expand All @@ -62,6 +65,8 @@ public class JwtCNonceHandler implements CNonceHandler {

public static final int NONCE_LENGTH_RANDOM_OFFSET = 15;

private static final long CONSUMED_NONCE_CACHE_CLOCK_SKEW_SECONDS = 60;

private static final Logger logger = Logger.getLogger(JwtCNonceHandler.class);

private final KeycloakSession keycloakSession;
Expand Down Expand Up @@ -168,6 +173,42 @@ public void verifyCNonce(String cNonce, List<String> audiences, @Nullable Map<St
.verifier(signingKey);
verifier.verifierContext(signatureVerifier);
verifier.verify(); // throws a VerificationException on failure

if (keycloakSession.singleUseObjects().contains(getCNonceSingleUseObjectKey(cNonce))) {
throw new VerificationException("c_nonce has already been used");
}
}

@Override
public void consumeCNonce(String cNonce) throws VerificationException {
JsonWebToken cNonceToken = TokenVerifier.create(cNonce, JsonWebToken.class).getToken();
Long exp = cNonceToken.getExp();
if (exp == null) {
throw new VerificationException("c_nonce has no expiration time");
}

long now = Time.currentTime();
long expiresIn = exp - now + CONSUMED_NONCE_CACHE_CLOCK_SKEW_SECONDS;
if (expiresIn <= 0) {
String message = String.format(
"c_nonce not valid: %s(exp) < %s(now)",
exp,
now);
throw new VerificationException(message);
}

SingleUseObjectProvider singleUseStore = keycloakSession.singleUseObjects();
String key = getCNonceSingleUseObjectKey(cNonce);
boolean firstInsertion = singleUseStore.putIfAbsent(key, expiresIn);
if (!firstInsertion) {
throw new VerificationException("c_nonce has already been used");
}
}

private static String getCNonceSingleUseObjectKey(String cNonce) {
String hash = HashUtils.sha256UrlEncodedHash(cNonce.trim(), StandardCharsets.UTF_8);
String fqcn = JwtCNonceHandler.class.getName().toLowerCase();
return fqcn + "." + hash;
}

protected boolean checkAttributeEquality(String key, Object object, Object actualValue) throws VerificationException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
Expand Down Expand Up @@ -80,6 +81,31 @@ public void testGetCNonce() throws Exception {
});
}

@Test
public void testCNonceCanOnlyBeConsumedOnce() throws Exception {
Oid4vcNonceResponse response = oauth.oid4vc().nonceRequest().send();
Assertions.assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode(),
"Nonce endpoint should return 200 OK");

String cNonce = response.getNonce();
Assertions.assertNotNull(cNonce);

runOnServer.run(session -> {
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
var keycloakContext = session.getContext();

cNonceHandler.verifyCNonce(cNonce,
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(keycloakContext)),
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
OID4VCIssuerWellKnownProvider.getNonceEndpoint(keycloakContext)));
cNonceHandler.consumeCNonce(cNonce);

VerificationException exception = Assertions.assertThrows(VerificationException.class,
() -> cNonceHandler.consumeCNonce(cNonce));
Assertions.assertEquals("c_nonce has already been used", exception.getMessage());
});
}

@Test
public void testDPoPNonceHeaderPresent() throws Exception {
// Clear events before nonce request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,35 @@ public void testCompleteFlowWithClaimsValidationAuthorizationCode_credentialRequ
assertSuccessfulCredentialResponse(credResponse);
}

/**
* Replaying the same proof JWT in a second credential request must fail because the
* proof c_nonce is consumed after the first successful request.
*/
@Test
public void testCredentialRequestRejectsReplayedProofCNonce() throws Exception {

CredentialIssuer issuer = wallet.getIssuerMetadata(ctx);
AccessTokenResponse tokenResponse = authzCodeFlow(issuer);
String credentialIdentifier = assertTokenResponse(tokenResponse);
Proofs proofs = newJwtProofs();

Oid4vcCredentialResponse firstResponse = oauth.oid4vc().credentialRequest()
.credentialIdentifier(credentialIdentifier)
.proofs(proofs)
.bearerToken(tokenResponse.getAccessToken())
.send();
assertSuccessfulCredentialResponse(firstResponse);

Oid4vcCredentialResponse replayResponse = oauth.oid4vc().credentialRequest()
.credentialIdentifier(credentialIdentifier)
.proofs(proofs)
.bearerToken(tokenResponse.getAccessToken())
.send();

assertEquals(400, replayResponse.getStatusCode());
assertEquals(ErrorType.INVALID_NONCE.getValue(), replayResponse.getError());
}

/** After refreshing the token the new access-token must still be usable for a credential request. */
@Test
public void testCompleteFlowWithClaimsValidationAuthorizationCode_refreshToken() throws Exception {
Expand Down
Loading