From be22a4bd62afedca52586b9828b6659bd118b733 Mon Sep 17 00:00:00 2001 From: forkimenjeckayang <104195313+forkimenjeckayang@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:08:01 +0100 Subject: [PATCH] [OID4VCI] Fix OID4VC wallet interoperability issues (#44682) closes #44736 Signed-off-by: forkimenjeckayang --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 24 +++++++-- .../keybinding/AttestationValidatorUtil.java | 17 ++++++- .../oid4vc/model/AttestationProof.java | 49 +++++++++++++++++++ .../oid4vc/model/CredentialRequest.java | 18 +++++-- .../protocol/oid4vc/model/JwtProof.java | 1 + .../oid4vc/model/SupportedProofTypeData.java | 9 ++++ .../signing/OID4VCAttestationProofTest.java | 31 ++++++++++++ .../oid4vc/issuance/signing/OID4VCTest.java | 16 ++++-- 8 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 1515ac6b026..82882cb815e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java index e95f66d2214..3387b40f547 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java @@ -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); + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java new file mode 100644 index 00000000000..706b0659db7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java @@ -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 OID4VCI Credential Request + */ +@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; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java index 0ca3baeba66..c424b9f02b0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java index 3506116a835..94dda7e3ac3 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java @@ -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 OID4VCI Credential Request */ diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java index de4659f076e..93aaa9f64e5 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java @@ -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; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java index 697bcfed5a4..4765c33fe30 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java @@ -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(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 6202548b0b4..20643398184 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -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 proofJwks, String cNonce) { + return createValidAttestationJwt(session, attestationKey, proofJwks, cNonce, + AttestationValidatorUtil.ATTESTATION_JWT_TYP); + } + + protected static String createValidAttestationJwt(KeycloakSession session, + KeyWrapper attestationKey, + List 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));