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:
parent
dc9b1136ef
commit
6fc69b6a01
9 changed files with 514 additions and 34 deletions
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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())))
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue