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
25 changes: 15 additions & 10 deletions core/src/main/java/org/keycloak/sdjwt/SdJwt.java
Original file line number Diff line number Diff line change
Expand Up @@ -252,16 +252,21 @@ public Builder withUseDefaultDecoys(boolean useDefaultDecoys) {
}

public SdJwt build() {
int numberOfDecoys = Optional.ofNullable(issuerSignedJwt.getDecoyClaims()).map(List::size).orElse(0);
if (useDefaultDecoys && numberOfDecoys == 0) {
List<DecoyClaim> decoyClaims = new ArrayList<>();
for (int i = 0; i < DEFAULT_NUMBER_OF_DECOYS; i++) {
decoyClaims.add(DecoyClaim.builder().build());
}
issuerSignedJwt.setDisclosureClaims(issuerSignedJwt.getDisclosureSpec(),
issuerSignedJwt.getDisclosureClaims(),
decoyClaims);
}
List<DecoyClaim> decoyClaims = Optional.ofNullable(issuerSignedJwt.getDecoyClaims())
.filter(list -> !list.isEmpty())
.orElseGet(() -> {
if (!useDefaultDecoys) {
return Collections.emptyList();
}
List<DecoyClaim> defaults = new ArrayList<>();
for (int i = 0; i < DEFAULT_NUMBER_OF_DECOYS; i++) {
defaults.add(DecoyClaim.builder().build());
}
return defaults;
});
issuerSignedJwt.setDisclosureClaims(issuerSignedJwt.getDisclosureSpec(),
issuerSignedJwt.getDisclosureClaims(),
decoyClaims);

SdJwt sdJwt = new SdJwt(issuerSignedJwt, keyBindingJWT, nestedSdJwts);
AtomicInteger signCounter = new AtomicInteger(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,40 @@

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

import java.util.Map;

import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;

import org.jboss.logging.Logger;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;

/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class JwtCredentialBody implements CredentialBody {

private static final Logger LOGGER = Logger.getLogger(JwtCredentialBody.class);

private final JWSBuilder.EncodingBuilder jwsEncodingBuilder;
private final JWSBuilder jwsBuilder;
private final JsonWebToken jsonWebToken;

public JwtCredentialBody(JWSBuilder.EncodingBuilder jwsEncodingBuilder) {
this.jwsEncodingBuilder = jwsEncodingBuilder;
public JwtCredentialBody(JWSBuilder jwsBuilder, JsonWebToken jsonWebToken) {
this.jwsBuilder = jwsBuilder;
this.jsonWebToken = jsonWebToken;
}

@Override
public void addKeyBinding(JWK jwk) throws CredentialBuilderException {
LOGGER.warnf("Key binding is not yet implemented for JWT credentials");
Map<String, Object> jwkMap = JsonSerialization
.mapper
.convertValue(jwk, JsonSerialization.mapper.getTypeFactory()
.constructMapType(Map.class, String.class, Object.class));
jsonWebToken.setOtherClaims(CLAIM_NAME_CNF, Map.of(CLAIM_NAME_JWK, jwkMap));
}

public String sign(SignatureSignerContext signatureSignerContext) {
return jwsEncodingBuilder.sign(signatureSignerContext);
return jwsBuilder.jsonContent(jsonWebToken).sign(signatureSignerContext);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,10 @@ public JwtCredentialBody buildCredentialBody(
.map(Object::toString)
.ifPresent(jsonWebToken::subject);

JWSBuilder.EncodingBuilder jwsBuilder = new JWSBuilder()
.type(credentialBuildConfig.getTokenJwsType())
.jsonContent(jsonWebToken);
JWSBuilder jwsBuilder = new JWSBuilder()
.type(credentialBuildConfig.getTokenJwsType());

return new JwtCredentialBody(jwsBuilder);
return new JwtCredentialBody(jwsBuilder, jsonWebToken);
}

private static List<String> getCredentialTypes(List<String> credentialTypes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import org.keycloak.jose.jwk.JWK;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.SdJwt;
import org.keycloak.sdjwt.SdJwtClaimName;
import org.keycloak.sdjwt.VisibleSdJwtClaim;
import org.keycloak.util.JsonSerialization;

import com.fasterxml.jackson.databind.node.ObjectNode;
Expand All @@ -45,7 +47,10 @@ public void addKeyBinding(JWK jwk) throws CredentialBuilderException {
ObjectNode jwkNode = JsonSerialization.mapper.convertValue(jwk, ObjectNode.class);
ObjectNode keyBindingNode = JsonSerialization.mapper.createObjectNode();
keyBindingNode.set(CLAIM_NAME_JWK, jwkNode);
issuerSignedJWT.getPayload().set(CLAIM_NAME_CNF, keyBindingNode);

SdJwtClaimName cnfName = SdJwtClaimName.of(CLAIM_NAME_CNF);
issuerSignedJWT.getDisclosureClaims().removeIf(claim -> claim != null && cnfName.equals(claim.getClaimName()));
issuerSignedJWT.getDisclosureClaims().add(new VisibleSdJwtClaim(cnfName, keyBindingNode));
}

public IssuerSignedJWT getIssuerSignedJWT() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@

package org.keycloak.tests.oid4vc.issuance.credentialbuilder;

import java.security.KeyPairGenerator;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
Expand All @@ -38,9 +41,13 @@
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;

import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
Expand Down Expand Up @@ -111,6 +118,41 @@ public void buildJwtCredential_AddsVerifiableCredentialType() throws Exception {
assertEquals("oid4vc_natural_person", credentialTypes.get(1).asText());
}

@Test
public void shouldBindHolderKeyWithCnfClaim() throws Exception {
VerifiableCredential verifiableCredential = getTestCredential(exampleCredentialClaims());
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig().setTokenJwsType("JWT");

// Build credential body
JwtCredentialBody jwtCredentialBody = builder
.buildCredentialBody(verifiableCredential, credentialBuildConfig);

// Generate a holder key and bind it
var holderKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
JWK holderJwk = JWKBuilder.create().kid("holder-key-1").rs256(holderKeyPair.getPublic());
jwtCredentialBody.addKeyBinding(holderJwk);

// Sign and parse JWS string
String jws = jwtCredentialBody.sign(exampleSigner());
JWSInput jwsInput = new JWSInput(jws);
JsonNode payload = jwsInput.readJsonContent(JsonNode.class);

// Assert cnf claim is present and contains the holder's JWK
JsonNode cnfNode = payload.get(CLAIM_NAME_CNF);
assertNotNull(cnfNode, "The cnf claim must be present in the JWT payload");

JsonNode jwkNode = cnfNode.get(CLAIM_NAME_JWK);
assertNotNull(jwkNode, "The cnf claim must contain a jwk field");
assertEquals("holder-key-1", jwkNode.get("kid").asText(),
"The bound JWK must have the holder's key ID");
assertTrue(jwkNode.has("n"), "The bound JWK must contain the RSA modulus");
assertTrue(jwkNode.has("e"), "The bound JWK must contain the RSA exponent");

// Verify the rest of the credential is intact
JsonNode credentialSubject = payload.get("vc").get(CREDENTIAL_SUBJECT);
assertEquals("randomValue", credentialSubject.get("randomKey").asText());
}

private JsonNode parseCredentialSubject(JWSInput jwsInput) throws JWSInputException {
JsonNode payload = jwsInput.readJsonContent(JsonNode.class);
return payload.get("vc").get(CREDENTIAL_SUBJECT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@

package org.keycloak.tests.oid4vc.issuance.credentialbuilder;

import java.security.KeyPairGenerator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;

import org.keycloak.OID4VCConstants;
import org.keycloak.VCFormat;
import org.keycloak.common.VerificationException;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBody;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
Expand All @@ -34,15 +39,21 @@
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
Expand Down Expand Up @@ -93,12 +104,52 @@ public void buildSdJwtCredential_WithNoClaims() throws Exception {
);
}

static Stream<Integer> decoyCountProvider() {
return Stream.of(0, 1, 5);
}

@ParameterizedTest
@MethodSource("decoyCountProvider")
public void shouldBindHolderKeyWithCnfClaim(int decoys) throws Exception {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_ISSUER_DID)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType(VCFormat.SD_JWT_VC)
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
.setNumberOfDecoys(decoys)
.setSdJwtVisibleClaims(List.of(CLAIM_NAME_CNF));

VerifiableCredential testCredential = getTestCredential(
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), "test", "value"));

SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder()
.buildCredentialBody(testCredential, credentialBuildConfig);

var holderKeyPair = KeyPairGenerator.getInstance("EC").generateKeyPair();
JWK holderJwk = JWKBuilder.create().kid("holder-key-1").ec(holderKeyPair.getPublic());
sdJwtCredentialBody.addKeyBinding(holderJwk);

String sdJwtString = sdJwtCredentialBody.sign(exampleSigner());
SdJwtVP sdJwt = SdJwtVP.of(sdJwtString);
IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT();

JsonNode cnfNode = jwt.getPayload().get(CLAIM_NAME_CNF);
assertNotNull(cnfNode, "The cnf claim must be present in the SD-JWT payload (decoys=" + decoys + ")");

JsonNode jwkNode = cnfNode.get(CLAIM_NAME_JWK);
assertNotNull(jwkNode, "The cnf claim must contain a jwk field");
assertEquals("holder-key-1", jwkNode.get("kid").asText(),
"The bound JWK must have the holder's key ID");
assertEquals("EC", jwkNode.get("kty").asText(),
"The bound JWK must have the correct key type");
}

public void testSignSDJwtCredential(Map<String, Object> claims, int decoys, List<String> visibleClaims)
throws VerificationException {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_ISSUER_DID)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setTokenJwsType(VCFormat.SD_JWT_VC)
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
.setNumberOfDecoys(decoys)
.setSdJwtVisibleClaims(visibleClaims);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.model.vcdm.LdProof;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.annotations.TestSetup;
Expand Down Expand Up @@ -65,7 +66,7 @@ public void testUnsupportedCredentialBody() throws Throwable {
CredentialSignerException.class,
() -> new LDCredentialSigner(session, new StaticTimeProvider(1000))
.signCredential(
new JwtCredentialBody(new JWSBuilder().jsonContent(Map.of())),
new JwtCredentialBody(new JWSBuilder(), new JsonWebToken()),
new CredentialBuildConfig()
)
)
Expand Down
Loading