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 @@ -222,7 +222,7 @@ private static void validateAttestationPayload(
}
}

private static KeyAttestationsRequired getAttestationRequirements(VCIssuanceContext vcIssuanceContext) {
public static KeyAttestationsRequired getAttestationRequirements(VCIssuanceContext vcIssuanceContext) {
if (vcIssuanceContext.getCredentialConfig() == null ||
vcIssuanceContext.getCredentialConfig().getProofTypesSupported() == null ||
vcIssuanceContext.getCredentialConfig().getProofTypesSupported().getSupportedProofTypes() == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.ProofType;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.Proofs;
Expand Down Expand Up @@ -313,6 +314,11 @@ private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jw
private KeyAttestationInfo resolveHeaderAttestation(VCIssuanceContext vcIssuanceContext, Map<String, Object> headerClaims)
throws JWSInputException, VerificationException {
if (!headerClaims.containsKey(KEY_ATTESTATION_CLAIM)) {
KeyAttestationsRequired attestationRequirements = AttestationValidatorUtil.getAttestationRequirements(vcIssuanceContext);
if (attestationRequirements != null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF,
"key_attestation JWT header claim is required by the credential configuration but was not provided");
}
return KeyAttestationInfo.absent();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import java.util.UUID;
import java.util.stream.Stream;

import org.keycloak.OID4VCConstants;
import org.keycloak.VCFormat;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource;
Expand Down Expand Up @@ -633,7 +632,7 @@ public RealmBuilder configure(RealmBuilder realm) {
sdJwtTypeCredentialVct,
VCFormat.SD_JWT_VC,
null,
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH, OID4VCConstants.KeyAttestationResistanceLevels.MODERATE)
null
);
Map<String, String> sdJwtAttrs = Optional.ofNullable(sdJwtScope.getAttributes()).orElseGet(HashMap::new);
sdJwtScope.setBindingRequired(true);
Expand All @@ -650,7 +649,7 @@ public RealmBuilder configure(RealmBuilder realm) {
null,
VCFormat.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE,
Collections.emptyList()
null
);
Map<String, String> jwtVcAttrs = Optional.ofNullable(jwtVcScope.getAttributes()).orElseGet(HashMap::new);
jwtVcAttrs.put(VC_BINDING_REQUIRED, "true");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,15 @@ public void testAttestationProofWithNonMatchingIdPConfiguredTrustedKeys() {
});
}

@Test
public void testJwtProofWithoutKeyAttestationWhenRequiredIsRejected() {
String cNonce = getCNonce();
runOnServer.run(session -> {
setupSessionContext(session);
runJwtProofWithoutKeyAttestationWhenRequiredIsRejected(session, cNonce);
});
}

@Test
public void testJwtProofMissingIssuerForClientBoundFlowAllowed() {
String cNonce = getCNonce();
Expand Down Expand Up @@ -449,7 +458,6 @@ public void testJwtProofWithFutureNbfRejected() {
});
}


private String getCNonce() {
return oauth.oid4vc().nonceRequest().send().getNonce();
}
Expand Down Expand Up @@ -483,6 +491,16 @@ private static VCIssuanceContext createVCIssuanceContext(KeycloakSession session
return context;
}

private static VCIssuanceContext createVCIssuanceContextWithoutAttestation(KeycloakSession session) {
VCIssuanceContext context = createVCIssuanceContext(session);
context.getCredentialConfig()
.getProofTypesSupported()
.getSupportedProofTypes()
.get(JWT)
.setKeyAttestationsRequired(null);
return context;
}

private static String createValidAttestationJwt(KeyWrapper attestationKey,
JWK proofJwk,
String cNonce) {
Expand Down Expand Up @@ -593,6 +611,39 @@ private static String generateJwtProofWithJwkAndKid(KeycloakSession session, Key
}
}

private static String generateJwtProofWithoutKeyAttestation(KeycloakSession session, KeyWrapper proofKey, String cNonce) {
try {
JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
proofJwk.setAlgorithm(proofKey.getAlgorithm());

AccessToken token = new AccessToken();
String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
token.addAudience(credentialIssuer);
token.setNonce(cNonce);
token.issuedNow();

// Build header manually to avoid JWSBuilder automatically adding kid from the signer,
// which would trigger the kid/jwk/x5c mutual exclusivity check before the attestation check.
Map<String, Object> header = new HashMap<>();
header.put("alg", proofKey.getAlgorithm());
header.put("typ", JwtProofValidator.PROOF_JWT_TYP);
header.put("jwk", proofJwk);

return new JWSBuilder() {
@Override
protected String encodeHeader(String sigAlgName) {
try {
return Base64Url.encode(JsonSerialization.writeValueAsBytes(header));
} catch (Exception e) {
throw new RuntimeException("Failed to encode header", e);
}
}
}.jsonContent(token).sign(new ECDSASignatureSignerContext(proofKey));
} catch (Exception e) {
throw new RuntimeException("Failed to generate JWT proof without key attestation", e);
}
}

private static String generateJwtProofWithX5c(KeycloakSession session, KeyWrapper proofKey,
X509Certificate cert, String cNonce) {
try {
Expand Down Expand Up @@ -854,7 +905,7 @@ private static void runValidX5cJwtProofWithoutAttestationTest(KeycloakSession se

String jwtProof = generateJwtProofWithX5c(session, proofKey, leafCert, cNonce);

VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithoutAttestation(session);
vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));

JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
Expand All @@ -872,7 +923,7 @@ private static void runJwtProofMissingIssuerForClientBoundFlowAllowedTest(Keyclo
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce, null,
null, null, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand All @@ -888,7 +939,7 @@ private static void runJwtProofWithWrongIssuerForClientBoundFlowRejectedTest(Key
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
"wrong-client-id", null, null, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand All @@ -902,7 +953,7 @@ private static void runJwtProofWithIssuerInAnonymousFlowRejectedTest(KeycloakSes
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, null, null, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(null);

Expand All @@ -916,7 +967,7 @@ private static void runJwtProofWithMultipleAudiencesRejectedTest(KeycloakSession
List.of(credentialIssuer, "https://unrelated.example"), cNonce, OID4VCIssuerTestBase.OID4VCI_CLIENT_ID,
null, null, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand All @@ -931,7 +982,7 @@ private static void runJwtProofWithFutureIatRejectedTest(KeycloakSession session
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now + 120, null, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand All @@ -946,7 +997,7 @@ private static void runJwtProofWithExpiredExpRejectedTest(KeycloakSession sessio
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now, now - 1, null);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand All @@ -955,13 +1006,41 @@ private static void runJwtProofWithExpiredExpRejectedTest(KeycloakSession sessio
assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
}

private static void runJwtProofWithoutKeyAttestationWhenRequiredIsRejected(KeycloakSession session, String cNonce) {
try {
KeyWrapper proofKey = getECKey("proofKey");

// Create context and add attestation requirements to the JWT proof type
VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
KeyAttestationsRequired requirements = new KeyAttestationsRequired();
requirements.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
vcIssuanceContext.getCredentialConfig()
.getProofTypesSupported()
.getSupportedProofTypes()
.get(JWT)
.setKeyAttestationsRequired(requirements);

// Generate a valid JWT proof WITHOUT key_attestation header
String jwtProof = generateJwtProofWithoutKeyAttestation(session, proofKey, cNonce);
vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));

JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));

VCIssuerException e = assertThrows(VCIssuerException.class, () -> validator.validateProof(vcIssuanceContext));
assertTrue(e.getMessage().contains("key_attestation"),
"Expected error about missing key_attestation but got: " + e.getMessage());
} catch (Exception e) {
throw new RuntimeException("Test for missing key_attestation when required failed", e);
}
}

private static void runJwtProofWithFutureNbfRejectedTest(KeycloakSession session, String cNonce) {
String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
long now = System.currentTimeMillis() / 1000L;
String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now, null, now + 120);

VCIssuanceContext context = createVCIssuanceContext(session);
VCIssuanceContext context = createVCIssuanceContextWithoutAttestation(session);
context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
Expand Down
Loading