[OID4VCI] Fix OID4VC wallet interoperability issues (#44682)

closes #44736


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang
2025-12-10 12:08:01 +01:00
committed by GitHub
parent f641269ac1
commit be22a4bd62
8 changed files with 154 additions and 11 deletions

View File

@@ -83,6 +83,7 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner;
import org.keycloak.protocol.oid4vc.model.AttestationProof;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
@@ -985,11 +986,28 @@ public class OID4VCIssuerEndpoint {
if (credentialRequest.getProof() != null) {
LOGGER.debugf("Converting single 'proof' field to 'proofs' array for backward compatibility");
JwtProof singleProof = credentialRequest.getProof();
Object singleProof = credentialRequest.getProof();
Proofs proofsArray = new Proofs();
if (singleProof.getJwt() != null) {
proofsArray.setJwt(List.of(singleProof.getJwt()));
// Handle AttestationProof
if (singleProof instanceof AttestationProof attestationProof) {
String attestationValue = attestationProof.getAttestation();
if (attestationValue != null) {
proofsArray.setAttestation(List.of(attestationValue));
}
}
// Handle JwtProof
else if (singleProof instanceof JwtProof jwtProof) {
String jwtValue = jwtProof.getJwt();
if (jwtValue != null) {
proofsArray.setJwt(List.of(jwtValue));
}
} else {
String message = "Unsupported proof type: " + (singleProof != null ? singleProof.getClass().getName() : "null");
LOGGER.debug(message);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, message));
}
credentialRequest.setProofs(proofsArray);
credentialRequest.setProof(null);
}

View File

@@ -67,6 +67,7 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.services.clientpolicy.executor.FapiConstant.ALLOWED_ALGORITHMS;
@@ -78,7 +79,11 @@ import static org.keycloak.services.clientpolicy.executor.FapiConstant.ALLOWED_A
*/
public class AttestationValidatorUtil {
private static final Logger LOGGER = Logger.getLogger(AttestationValidatorUtil.class);
public static final String ATTESTATION_JWT_TYP = "key-attestation+jwt";
@Deprecated
public static final String LEGACY_ATTESTATION_JWT_TYP = "keyattestation+jwt";
private static final String CACERTS_PATH = System.getProperty("javax.net.ssl.trustStore",
System.getProperty("java.home") + "/lib/security/cacerts");
private static final char[] DEFAULT_TRUSTSTORE_PASSWORD = System.getProperty(
@@ -291,8 +296,16 @@ public class AttestationValidatorUtil {
". Allowed algorithms: " + ALLOWED_ALGORITHMS);
}
if (!ATTESTATION_JWT_TYP.equals(header.getType())) {
throw new VCIssuerException("Invalid JWT typ: expected " + ATTESTATION_JWT_TYP);
String typ = Optional.ofNullable(header.getType())
.map(Object::toString)
.orElseThrow(() -> new VCIssuerException("Missing typ in JWS header"));
if (!ATTESTATION_JWT_TYP.equals(typ)) {
if (LEGACY_ATTESTATION_JWT_TYP.equals(typ)) {
LOGGER.debugf("Accepting deprecated attestation JWT typ '%s' for backward compatibility", typ);
} else {
throw new VCIssuerException("Invalid JWT typ: expected " + ATTESTATION_JWT_TYP);
}
}
}

View File

@@ -0,0 +1,49 @@
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Deprecated: Represents a single attestation-based proof (historical 'proof' structure).
* Prefer using {@link Proofs} with the appropriate array field (e.g., attestation).
* This class is kept for backward compatibility only.
* Supports 'attestation' proof type as per OID4VCI Draft 15.
*
* @see <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-ID2.html#name-credential-request">OID4VCI Credential Request</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@Deprecated
public class AttestationProof {
@JsonProperty("attestation")
private String attestation;
@JsonProperty("proof_type")
private String proofType;
public AttestationProof() {
}
public AttestationProof(String attestation, String proofType) {
this.attestation = attestation;
this.proofType = proofType;
}
public String getAttestation() {
return attestation;
}
public AttestationProof setAttestation(String attestation) {
this.attestation = attestation;
return this;
}
public String getProofType() {
return proofType;
}
public AttestationProof setProofType(String proofType) {
this.proofType = proofType;
return this;
}
}

View File

@@ -28,6 +28,8 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
/**
@@ -52,10 +54,20 @@ public class CredentialRequest {
/**
* Deprecated: use {@link #proofs} instead.
* This field is kept only for backward compatibility with clients sending a single 'proof'.
* Can be either {@link JwtProof} or {@link AttestationProof} depending on the proof type.
*/
@Deprecated
@JsonProperty("proof")
private JwtProof proof;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "proof_type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = JwtProof.class, name = "jwt"),
@JsonSubTypes.Type(value = AttestationProof.class, name = "attestation")
})
private Object proof;
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3
@JsonProperty("credential_definition")
@@ -91,11 +103,11 @@ public class CredentialRequest {
return this;
}
public JwtProof getProof() {
public Object getProof() {
return proof;
}
public CredentialRequest setProof(JwtProof proof) {
public CredentialRequest setProof(Object proof) {
this.proof = proof;
return this;
}

View File

@@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
* Deprecated: Represents a single JWT-based proof (historical 'proof' structure).
* Prefer using {@link Proofs} with the appropriate array field (e.g., jwt).
* This class is kept for backward compatibility only.
* Supports 'jwt' proof type as per OID4VCI Draft 15.
*
* @see <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-15.html#name-credential-request">OID4VCI Credential Request</a>
*/

View File

@@ -58,6 +58,15 @@ public class SupportedProofTypeData {
return this;
}
/**
* Returns the key attestations required.
* According to the spec:
* - If the Credential Issuer does not require a key attestation, this parameter MUST NOT be present (should be null).
* - If both key_storage and user_authentication parameters are absent, the key_attestations_required parameter
* may be empty (both fields null), indicating a key attestation is needed without additional constraints.
*
* @return KeyAttestationsRequired object, or null if attestation is not required
*/
public KeyAttestationsRequired getKeyAttestationsRequired() {
return keyAttestationsRequired;
}

View File

@@ -57,6 +57,37 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
});
}
@Test
public void testAttestationProofAcceptsLegacyTyp() {
String cNonce = getCNonce();
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
KeyWrapper attestationKey = createECKey("legacyAttestationKey");
KeyWrapper proofKey = createECKey("legacyProofKey");
JWK proofJwk = createJWK(proofKey);
String attestationJwt = createValidAttestationJwt(
session,
attestationKey,
List.of(proofJwk),
cNonce,
AttestationValidatorUtil.LEGACY_ATTESTATION_JWT_TYP);
configureTrustedKeysInRealm(session, List.of(createJWK(attestationKey)));
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
validateProofAndAssert(validator, vcIssuanceContext, proofKey);
} catch (Exception e) {
LOGGER.error("Legacy typ test failed with exception", e);
fail("Legacy typ attestation proof should be accepted: " + e.getMessage());
}
});
}
@Test
public void testAttestationProofExtractsAttestedKeysFromPayload() {
String cNonce = getCNonce();

View File

@@ -641,13 +641,23 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
KeyWrapper attestationKey,
JWK proofJwk,
String cNonce) {
return createValidAttestationJwt(session, attestationKey, List.of(proofJwk), cNonce);
}
return createValidAttestationJwt(session, attestationKey, List.of(proofJwk), cNonce,
AttestationValidatorUtil.ATTESTATION_JWT_TYP);
}
protected static String createValidAttestationJwt(KeycloakSession session,
KeyWrapper attestationKey,
List<JWK> proofJwks,
String cNonce) {
return createValidAttestationJwt(session, attestationKey, proofJwks, cNonce,
AttestationValidatorUtil.ATTESTATION_JWT_TYP);
}
protected static String createValidAttestationJwt(KeycloakSession session,
KeyWrapper attestationKey,
List<JWK> proofJwks,
String cNonce,
String typ) {
try {
KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
@@ -657,7 +667,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH));
return new JWSBuilder()
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
.type(typ)
.kid(attestationKey.getKid())
.jsonContent(payload)
.sign(new ECDSASignatureSignerContext(attestationKey));