Issue Verifiable Credentials in the SD-JWT-VC format (#27207)

closes #25942

Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>


Co-authored-by: Francis Pouatcha <francis.pouatcha@adorsys.com>
This commit is contained in:
Stefan Wiedemann 2024-03-11 08:55:28 +01:00 committed by GitHub
parent dc9b1136ef
commit 6fc69b6a01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 514 additions and 34 deletions

View file

@ -34,9 +34,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
* of existing VCs as well as the creation and signing of new ones.
* It integrates with Keycloak's SignatureSignerContext to facilitate
* the generation of issuer signature.
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*/
public class IssuerSignedJWT extends SdJws {
@ -54,7 +53,7 @@ public class IssuerSignedJWT extends SdJws {
}
private IssuerSignedJWT(List<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, String hashAlg,
boolean nestedDisclosures) {
boolean nestedDisclosures) {
super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures));
}
@ -63,7 +62,7 @@ public class IssuerSignedJWT extends SdJws {
}
private IssuerSignedJWT(List<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, String hashAlg,
boolean nestedDisclosures, SignatureSignerContext signer, String jwsType) {
boolean nestedDisclosures, SignatureSignerContext signer, String jwsType) {
super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures), signer, jwsType);
}
@ -72,7 +71,7 @@ public class IssuerSignedJWT extends SdJws {
* of claims.
*/
private static JsonNode generatePayloadString(List<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, String hashAlg,
boolean nestedDisclosures) {
boolean nestedDisclosures) {
SdJwtUtils.requireNonEmpty(hashAlg, "hashAlg must not be null or empty");
final List<SdJwtClaim> claimsInternal = claims == null ? Collections.emptyList()
@ -150,7 +149,7 @@ public class IssuerSignedJWT extends SdJws {
private SignatureSignerContext signer;
private List<DecoyClaim> decoyClaims;
private boolean nestedDisclosures;
private String jwsType = "vc+sd-jwt";
private String jwsType;
public Builder withClaims(List<SdJwtClaim> claims) {
this.claims = claims;
@ -185,6 +184,7 @@ public class IssuerSignedJWT extends SdJws {
public IssuerSignedJWT build() {
// Preinitialize hashAlg to sha-256 if not provided
hashAlg = hashAlg == null ? "sha-256" : hashAlg;
jwsType = jwsType == null ? "vc+sd-jwt" : jwsType;
// send an empty lise if claims not set.
claims = claims == null ? Collections.emptyList() : claims;
decoyClaims = decoyClaims == null ? Collections.emptyList() : decoyClaims;

View file

@ -34,9 +34,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Main entry class for selective disclosure jwt (SD-JWT).
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*/
public class SdJwt {
public static final String DELIMITER = "~";
@ -45,11 +44,11 @@ public class SdJwt {
private final List<SdJwtClaim> claims;
private final List<String> disclosures = new ArrayList<>();
private Optional<String> sdJwtString = Optional.empty();
private SdJwt(DisclosureSpec disclosureSpec, JsonNode claimSet, List<SdJwt> nesteSdJwts,
Optional<KeyBindingJWT> keyBindingJWT,
SignatureSignerContext signer) {
Optional<KeyBindingJWT> keyBindingJWT,
SignatureSignerContext signer,
String hashAlgorithm,
String jwsType) {
claims = new ArrayList<>();
claimSet.fields()
.forEachRemaining(entry -> claims.add(createClaim(entry.getKey(), entry.getValue(), disclosureSpec)));
@ -59,12 +58,16 @@ public class SdJwt {
.withDecoyClaims(createdDecoyClaims(disclosureSpec))
.withNestedDisclosures(!nesteSdJwts.isEmpty())
.withSigner(signer)
.withHashAlg(hashAlgorithm)
.withJwsType(jwsType)
.build();
nesteSdJwts.stream().forEach(nestedJwt -> this.disclosures.addAll(nestedJwt.getDisclosures()));
this.disclosures.addAll(getDisclosureStrings(claims));
}
private Optional<String> sdJwtString = Optional.empty();
private List<DecoyClaim> createdDecoyClaims(DisclosureSpec disclosureSpec) {
return disclosureSpec.getDecoyClaims().stream()
.map(disclosureData -> DecoyClaim.builder().withSalt(disclosureData.getSalt()).build())
@ -73,9 +76,9 @@ public class SdJwt {
/**
* Prepare to a nested payload to this SD-JWT.
*
* <p>
* droping the algo claim.
*
*
* @param nestedSdJwt
* @return
*/
@ -147,8 +150,8 @@ public class SdJwt {
}
private SdJwtClaim createArrayDisclosure(String claimName, JsonNode claimValue,
Map<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts,
Map<Integer, DisclosureSpec.DisclosureData> decoyArrayElts) {
Map<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts,
Map<Integer, DisclosureSpec.DisclosureData> decoyArrayElts) {
ArrayNode arrayNode = validateArrayNode(claimName, claimValue);
ArrayDisclosure.Builder arrayDisclosureBuilder = ArrayDisclosure.builder().withClaimName(claimName);
@ -175,7 +178,7 @@ public class SdJwt {
}
private void processArrayElement(ArrayDisclosure.Builder builder, JsonNode elementValue,
DisclosureSpec.DisclosureData disclosureData) {
DisclosureSpec.DisclosureData disclosureData) {
if (disclosureData != null) {
builder.withUndisclosedElement(disclosureData.getSalt(), elementValue);
} else {
@ -198,6 +201,8 @@ public class SdJwt {
private Optional<KeyBindingJWT> keyBindingJWT = Optional.empty();
private SignatureSignerContext signer;
private final List<SdJwt> nestedSdJwts = new ArrayList<>();
private String hashAlgorithm;
private String jwsType;
public Builder withDisclosureSpec(DisclosureSpec disclosureSpec) {
this.disclosureSpec = disclosureSpec;
@ -224,8 +229,18 @@ public class SdJwt {
return this;
}
public Builder withHashAlgorithm(String hashAlgorithm) {
this.hashAlgorithm = hashAlgorithm;
return this;
}
public Builder withJwsType(String jwsType) {
this.jwsType = jwsType;
return this;
}
public SdJwt build() {
return new SdJwt(disclosureSpec, claimSet, nestedSdJwts, keyBindingJWT, signer);
return new SdJwt(disclosureSpec, claimSet, nestedSdJwts, keyBindingJWT, signer, hashAlgorithm, jwsType);
}
}

View file

@ -105,7 +105,7 @@ public class JwtSigningService extends SigningService<String> {
}
// retrieve the credential id from the given VC or generate one.
private String createCredentialId(VerifiableCredential verifiableCredential) {
static String createCredentialId(VerifiableCredential verifiableCredential) {
return Optional.ofNullable(
verifiableCredential.getId())
.orElse(URI.create(String.format(ID_TEMPLATE, UUID.randomUUID())))

View file

@ -17,8 +17,25 @@
package org.keycloak.protocol.oid4vc.issuance.signing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.logging.Logger;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.sdjwt.DisclosureSpec;
import org.keycloak.sdjwt.SdJwt;
import org.keycloak.sdjwt.SdJwtUtils;
import java.util.List;
import java.util.Optional;
import java.util.StringJoiner;
import java.util.stream.IntStream;
/**
* {@link VerifiableCredentialsSigningService} implementing the SD_JWT_VC format. It returns a String, containing
@ -31,12 +48,98 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
*/
public class SdJwtSigningService extends SigningService<String> {
public SdJwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType) {
private static final Logger LOGGER = Logger.getLogger(SdJwtSigningService.class);
private static final String ISSUER_CLAIM ="iss";
private static final String NOT_BEFORE_CLAIM ="nbf";
private static final String VERIFIABLE_CREDENTIAL_TYPE_CLAIM = "vct";
private static final String CREDENTIAL_ID_CLAIM = "jti";
private final ObjectMapper objectMapper;
private final SignatureSignerContext signatureSignerContext;
private final TimeProvider timeProvider;
private final String tokenType;
private final String hashAlgorithm;
private final int decoys;
private final List<String> visibleClaims;
protected final String issuerDid;
public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List<String> visibleClaims, TimeProvider timeProvider, Optional<String> kid) {
super(keycloakSession, keyId, algorithmType);
this.objectMapper = objectMapper;
this.issuerDid = issuerDid;
this.timeProvider = timeProvider;
this.tokenType = tokenType;
this.hashAlgorithm = hashAlgorithm;
this.decoys = decoys;
this.visibleClaims = visibleClaims;
KeyWrapper signingKey = getKey(keyId, algorithmType);
if (signingKey == null) {
throw new SigningServiceException(String.format("No key for id %s and algorithm %s available.", keyId, algorithmType));
}
// set the configured kid if present.
if (kid.isPresent()) {
// we need to clone the key first, to not change the kid of the original key so that the next request still can find it.
signingKey = signingKey.cloneKey();
signingKey.setKid(keyId);
}
kid.ifPresent(signingKey::setKid);
SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, algorithmType);
signatureSignerContext = signatureProvider.signer(signingKey);
LOGGER.debugf("Successfully initiated the SD-JWT Signing Service with algorithm %s.", algorithmType);
}
@Override
public String signCredential(VerifiableCredential verifiableCredential) {
throw new UnsupportedOperationException("SD-JWT Signing is not yet supported.");
DisclosureSpec.Builder disclosureSpecBuilder = DisclosureSpec.builder();
CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject();
JsonNode claimSet = objectMapper.valueToTree(credentialSubject);
// put all claims into the disclosure spec, except the one to be kept visible
credentialSubject.getClaims()
.entrySet()
.stream()
.filter(entry -> !visibleClaims.contains(entry.getKey()))
.forEach(entry -> {
if (entry instanceof List<?> listValue) {
IntStream.range(0, listValue.size())
.forEach(i -> disclosureSpecBuilder.withUndisclosedArrayElt(entry.getKey(), i, SdJwtUtils.randomSalt()));
} else {
disclosureSpecBuilder.withUndisclosedClaim(entry.getKey(), SdJwtUtils.randomSalt());
}
});
// add the configured number of decoys
if (decoys != 0) {
IntStream.range(0, decoys)
.forEach(i -> disclosureSpecBuilder.withDecoyClaim(SdJwtUtils.randomSalt()));
}
ObjectNode rootNode = claimSet.withObject("");
rootNode.put(ISSUER_CLAIM, issuerDid);
// Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not
// provided
long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate())
.map(issuanceDate -> issuanceDate.toInstant().getEpochSecond())
.orElse((long) timeProvider.currentTimeSeconds());
rootNode.put(NOT_BEFORE_CLAIM, iat);
if (verifiableCredential.getType() == null || verifiableCredential.getType().size() != 1) {
throw new SigningServiceException("SD-JWT only supports single type credentials.");
}
rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, verifiableCredential.getType().get(0));
rootNode.put(CREDENTIAL_ID_CLAIM, JwtSigningService.createCredentialId(verifiableCredential));
SdJwt sdJwt = SdJwt.builder()
.withDisclosureSpec(disclosureSpecBuilder.build())
.withClaimSet(claimSet)
.withSigner(signatureSignerContext)
.withHashAlgorithm(hashAlgorithm)
.withJwsType(tokenType)
.build();
return sdJwt.toSdJwtString();
}
}

View file

@ -17,16 +17,21 @@
package org.keycloak.protocol.oid4vc.issuance.signing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.provider.ConfigurationValidationHelper;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Provider Factory to create {@link SdJwtSigningService}s
@ -42,8 +47,24 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi
public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) {
String keyId = model.get(SigningProperties.KEY_ID.getKey());
String algorithmType = model.get(SigningProperties.ALGORITHM_TYPE.getKey());
String tokenType = model.get(SigningProperties.TOKEN_TYPE.getKey());
String hashAlgorithm = model.get(SigningProperties.HASH_ALGORITHM.getKey());
Optional<String> kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey()));
int decoys = Integer.valueOf(model.get(SigningProperties.DECOYS.getKey()));
return new SdJwtSigningService(session, keyId, algorithmType);
List<String> visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey()))
.map(visibileClaims -> visibileClaims.split(","))
.map(Arrays::asList)
.orElse(List.of());
String issuerDid = Optional.ofNullable(
session
.getContext()
.getRealm()
.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY))
.orElseThrow(() -> new VCIssuerException("No issuerDid configured."));
return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, issuerDid, decoys, visibleClaims, new OffsetTimeProvider(), kid);
}
@Override
@ -55,7 +76,10 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi
public List<ProviderConfigProperty> getConfigProperties() {
return VCSigningServiceProviderFactory.configurationBuilder()
.property(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
.property(SigningProperties.TOKEN_TYPE.asConfigProperty())
.property(SigningProperties.DECOYS.asConfigProperty())
.property(SigningProperties.KID_HEADER.asConfigProperty())
.property(SigningProperties.HASH_ALGORITHM.asConfigProperty())
.build();
}
@ -67,7 +91,9 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi
@Override
public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
ConfigurationValidationHelper.check(model)
.checkRequired(SigningProperties.HASH_ALGORITHM.asConfigProperty())
.checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
.checkRequired(SigningProperties.TOKEN_TYPE.asConfigProperty())
.checkInt(SigningProperties.DECOYS.asConfigProperty(), true);
}

View file

@ -29,11 +29,14 @@ public enum SigningProperties {
ISSUER_DID("issuerDid", "Did of the issuer.", "Provide the DID of the issuer. Needs to match the provided key material.", ProviderConfigProperty.STRING_TYPE, null),
KEY_ID("keyId", "Id of the signing key.", "The id of the key to be used for signing credentials. The key needs to be provided as a realm key.", ProviderConfigProperty.STRING_TYPE, null),
KID_HEADER("kidHeader", "Kid to be set for the JWT.", "The kid to be set in the jwt-header. Depending on the did-schema, the pure key-id might not be enough and can be overwritten here.", ProviderConfigProperty.STRING_TYPE, null),
PROOF_TYPE("proofType", "Type of the LD-Proof.", "The type of LD-Proofs to be created. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, null),
ALGORITHM_TYPE("algorithmType", "Type of the signing algorithm.", "The type of the algorithm to be used for signing. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, Algorithm.RS256),
TOKEN_TYPE("tokenType", "Type of the token.", "The type of the token to be created. Will be used as `typ` claim in the JWT-Header.", ProviderConfigProperty.STRING_TYPE, "JWT"),
DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0);
DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0),
HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"),
VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null);
private final String key;
private final String label;

View file

@ -214,17 +214,7 @@ public class JwtSigningServiceTest extends SigningServiceTest {
}
}
private static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) {
// we only set one key to the realm, thus can just take the first one
// if run inside the testsuite, configure is called seperated from the test itself, thus we cannot just take
// the key from the `configureTestRealm` method.
return keycloakSession
.keys()
.getKeysStream(keycloakSession.getContext().getRealm())
.findFirst()
.orElseThrow(() -> new RuntimeException("No key was configured"));
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
if (testRealm.getComponents() != null) {

View file

@ -0,0 +1,331 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.ServerECDSASignatureVerifierContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService;
import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService;
import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.sdjwt.SdJwtUtils;
import org.keycloak.testsuite.runonserver.RunOnServerException;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.StringJoiner;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class SdJwtSigningServiceTest extends SigningServiceTest {
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static KeyWrapper rsaKey = getRsaKey();
// If an unsupported algorithm is provided, the JWT Sigining Service should not be instantiated.
@Test(expected = SigningServiceException.class)
public void testUnsupportedAlgorithm() throws Throwable {
try {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
new SdJwtSigningService(
session,
new ObjectMapper(),
getKeyFromSession(session).getKid(),
"unsupported-algorithm",
"JWT",
"sha-256",
"did:web:test.org",
0,
List.of(),
new StaticTimeProvider(1000),
Optional.empty()));
} catch (RunOnServerException ros) {
throw ros.getCause();
}
}
// If no key is provided, the JWT Sigining Service should not be instantiated.
@Test(expected = SigningServiceException.class)
public void testFailIfNoKey() throws Throwable {
try {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
new JwtSigningService(
session,
"no-such-key",
Algorithm.RS256,
"JWT",
"did:web:test.org",
new StaticTimeProvider(1000)));
} catch (RunOnServerException ros) {
throw ros.getCause();
}
}
@Test
public void testRsaSignedCredentialWithClaims() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignSDJwtCredential(
session,
Optional.empty(),
Algorithm.RS256,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c")),
0,
List.of()));
}
@Test
public void testRsaSignedCredentialWithVisibleClaims() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignSDJwtCredential(
session,
Optional.empty(),
Algorithm.RS256,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c")),
0,
List.of("test")));
}
@Test
public void testRsaSignedCredentialWithClaimsAndDecoys() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignSDJwtCredential(
session,
Optional.empty(),
Algorithm.RS256,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c")),
6,
List.of()));
}
@Test
public void testRsaSignedCredentialWithKeyId() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignSDJwtCredential(
session,
Optional.of("did:web:test.org#key-id"),
Algorithm.RS256,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c")),
0,
List.of()));
}
@Test
public void testRsaSignedCredentialWithoutAdditionalClaims() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignSDJwtCredential(
session,
Optional.empty(),
Algorithm.RS256,
Map.of(),
0,
List.of()));
}
public static void testSignSDJwtCredential(KeycloakSession session, Optional<String> keyId, String
algorithm, Map<String, Object> claims, int decoys, List<String> visibleClaims) {
KeyWrapper keyWrapper = getKeyFromSession(session);
SdJwtSigningService signingService = new SdJwtSigningService(
session,
new ObjectMapper(),
keyWrapper.getKid(),
algorithm,
"vc+sd-jwt",
"sha-256",
"did:web:test.org",
decoys,
visibleClaims,
new StaticTimeProvider(1000),
keyId);
VerifiableCredential testCredential = getTestCredential(claims);
String sdJwt = signingService.signCredential(testCredential);
SignatureVerifierContext verifierContext = null;
switch (algorithm) {
case Algorithm.ES256: {
verifierContext = new ServerECDSASignatureVerifierContext(keyWrapper);
break;
}
case Algorithm.RS256: {
verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper);
break;
}
default: {
fail("Algorithm not supported.");
}
}
// the sd-jwt is dot-concatenated header.payload.signature~disclosure1~___~disclosureN
// we first split the disclosuers
String[] splittedSdToken = sdJwt.split("~");
// and then split the actual token part
String[] splittedToken = splittedSdToken[0].split("\\.");
String jwt = new StringJoiner(".")
// header
.add(splittedToken[0])
// payload
.add(splittedToken[1])
// signature
.add(splittedToken[2])
.toString();
TokenVerifier<JsonWebToken> verifier = TokenVerifier
.create(jwt, JsonWebToken.class)
.verifierContext(verifierContext);
verifier.publicKey((PublicKey) keyWrapper.getPublicKey());
try {
verifier.verify();
} catch (VerificationException e) {
fail("The credential should successfully be verified.");
}
try {
JsonWebToken theToken = verifier.getToken();
assertEquals("The issuer should be set in the token.", TEST_DID.toString(), theToken.getIssuer());
assertEquals("The credential ID should be set as the token ID.", testCredential.getId().toString(), theToken.getId());
assertEquals("The type should be included", TEST_TYPES.get(0), theToken.getOtherClaims().get("vct"));
assertEquals("The nbf date should be included", TEST_ISSUANCE_DATE.toInstant().getEpochSecond(), theToken.getNbf().longValue());
List<String> sds = (List<String>) theToken.getOtherClaims().get("_sd");
if (sds != null && !sds.isEmpty()){
assertEquals("The algorithm should be included", "sha-256", theToken.getOtherClaims().get("_sd_alg"));
}
List<String> disclosed = Arrays.asList(splittedSdToken).subList(1, splittedSdToken.length);
int numSds = sds != null ? sds.size() : 0;
assertEquals("All undisclosed claims and decoys should be provided.", disclosed.size() + decoys, numSds);
verifyDisclosures(sds, disclosed);
visibleClaims
.forEach(vc -> assertTrue("The visible claims should be present within the token.",theToken.getOtherClaims().containsKey(vc)));
} catch (VerificationException e) {
fail("Was not able to extract the token.");
}
}
private static void verifyDisclosures(List<String> undisclosed, List<String> disclosedList) {
disclosedList.stream()
.map(disclosed -> new String(Base64.getUrlDecoder().decode(disclosed)))
.map(disclosedString -> {
try {
return OBJECT_MAPPER.readValue(disclosedString, List.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
})
.map(dl -> new DisclosedClaim((String) dl.get(0), (String) dl.get(1), dl.get(2)))
.forEach(dc -> assertTrue("Every disclosure claim should be provided in the undisclosures.", undisclosed.contains(dc.getHash())));
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
if (testRealm.getComponents() != null) {
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(rsaKey));
} else {
testRealm.setComponents(new MultivaluedHashMap<>(
Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(rsaKey)))));
}
}
static class DisclosedClaim {
private final String salt;
private final String key;
private final Object value;
private final String hash;
DisclosedClaim(String salt, String key, Object value) {
this.salt = salt;
this.key = key;
this.value = value;
this.hash = createHash(salt, key, value);
}
private String createHash(String salt, String key, Object value) {
try {
return SdJwtUtils.encodeNoPad(
HashUtils.hash("sha-256",
SdJwtUtils.encodeNoPad(
SdJwtUtils.printJsonArray(List.of(salt, key, value).toArray()).getBytes()).getBytes()));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public String getSalt() {
return salt;
}
public String getKey() {
return key;
}
public Object getValue() {
return value;
}
public String getHash() {
return hash;
}
}
}

View file

@ -25,6 +25,7 @@ import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
@ -161,6 +162,17 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest {
return componentExportRepresentation;
}
protected static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) {
// we only set one key to the realm, thus can just take the first one
// if run inside the testsuite, configure is called seperated from the test itself, thus we cannot just take
// the key from the `configureTestRealm` method.
return keycloakSession
.keys()
.getKeysStream(keycloakSession.getContext().getRealm())
.findFirst()
.orElseThrow(() -> new RuntimeException("No key was configured"));
}
static class StaticTimeProvider implements TimeProvider {
private final int currentTimeInS;