mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
[OID4VCI] Fix OID4VC wallet interoperability issues (#44682)
closes #44736 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f641269ac1
commit
be22a4bd62
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user