Issue Verifiable Credentials in the VCDM format #25943 (#27071)

closes #25943


Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>
This commit is contained in:
Stefan Wiedemann 2024-03-18 17:05:53 +01:00 committed by GitHub
parent 9fb766bbce
commit 67d3e1e467
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 660 additions and 8 deletions

15
pom.xml
View file

@ -146,6 +146,10 @@
<google.zxing.version>3.4.0</google.zxing.version> <google.zxing.version>3.4.0</google.zxing.version>
<freemarker.version>2.3.32</freemarker.version> <freemarker.version>2.3.32</freemarker.version>
<!-- json-ld processing for oid4vci ld-proofs -->
<com.apicatalog.titanium-json-ld.version>1.3.3</com.apicatalog.titanium-json-ld.version>
<io.setl.rdf-urdna.version>1.1</io.setl.rdf-urdna.version>
<jetty9.version>${jetty94.version}</jetty9.version> <jetty9.version>${jetty94.version}</jetty9.version>
<liquibase.version>4.25.1</liquibase.version> <liquibase.version>4.25.1</liquibase.version>
<!-- matches quarkus 3.7.1 version and but also the pax.web.version, hence we can't rely on quarkus bom --> <!-- matches quarkus 3.7.1 version and but also the pax.web.version, hence we can't rely on quarkus bom -->
@ -1664,6 +1668,17 @@
<scope>test</scope> <scope>test</scope>
<version>${version.org.wildfly.glow}</version> <version>${version.org.wildfly.glow}</version>
</dependency> </dependency>
<dependency>
<groupId>com.apicatalog</groupId>
<artifactId>titanium-json-ld</artifactId>
<version>${com.apicatalog.titanium-json-ld.version}</version>
</dependency>
<dependency>
<groupId>io.setl</groupId>
<artifactId>rdf-urdna</artifactId>
<version>${io.setl.rdf-urdna.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View file

@ -100,6 +100,15 @@
<artifactId>quarkus-logging-json</artifactId> <artifactId>quarkus-logging-json</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.apicatalog</groupId>
<artifactId>titanium-json-ld</artifactId>
</dependency>
<dependency>
<groupId>io.setl</groupId>
<artifactId>rdf-urdna</artifactId>
</dependency>
<!-- SmallRye --> <!-- SmallRye -->
<dependency> <dependency>
<groupId>io.smallrye.config</groupId> <groupId>io.smallrye.config</groupId>

View file

@ -187,6 +187,16 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>com.apicatalog</groupId>
<artifactId>titanium-json-ld</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.setl</groupId>
<artifactId>rdf-urdna</artifactId>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>junit</groupId> <groupId>junit</groupId>
<artifactId>junit</artifactId> <artifactId>junit</artifactId>

View file

@ -18,8 +18,21 @@
package org.keycloak.protocol.oid4vc.issuance.signing; package org.keycloak.protocol.oid4vc.issuance.signing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.common.util.Base64;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.Ed255192018Suite;
import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.LinkedDataCryptographicSuite;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.model.vcdm.LdProof;
import java.io.IOException;
import java.time.Instant;
import java.util.Date;
import java.util.Optional;
/** /**
* {@link VerifiableCredentialsSigningService} implementing the LDP_VC format. It returns a Verifiable Credential, * {@link VerifiableCredentialsSigningService} implementing the LDP_VC format. It returns a Verifiable Credential,
@ -31,16 +44,56 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
*/ */
public class LDSigningService extends SigningService<VerifiableCredential> { public class LDSigningService extends SigningService<VerifiableCredential> {
private final LinkedDataCryptographicSuite linkedDataCryptographicSuite;
private final TimeProvider timeProvider;
private final String keyId;
public LDSigningService(KeycloakSession keycloakSession, String keyId, String ldpType) { public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, ObjectMapper objectMapper, TimeProvider timeProvider, Optional<String> kid) {
super(keycloakSession, keyId, ldpType); super(keycloakSession, keyId, algorithmType);
this.timeProvider = timeProvider;
this.keyId = kid.orElse(keyId);
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);
}
SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, algorithmType);
linkedDataCryptographicSuite = switch (ldpType) {
case Ed255192018Suite.PROOF_TYPE ->
new Ed255192018Suite(objectMapper, signatureProvider.signer(signingKey));
default -> throw new SigningServiceException(String.format("Proof Type %s is not supported.", ldpType));
};
} }
@Override @Override
public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) {
return addProof(verifiableCredential);
throw new UnsupportedOperationException("LD-Credentials Signing is not yet supported.");
} }
// add the signed proof to the credential.
private VerifiableCredential addProof(VerifiableCredential verifiableCredential) {
byte[] signature = linkedDataCryptographicSuite.getSignature(verifiableCredential);
LdProof ldProof = new LdProof();
ldProof.setProofPurpose("assertionMethod");
ldProof.setType(linkedDataCryptographicSuite.getProofType());
ldProof.setCreated(Date.from(Instant.ofEpochSecond(timeProvider.currentTimeSeconds())));
ldProof.setVerificationMethod(keyId);
try {
var proofValue = Base64.encodeBytes(signature, Base64.URL_SAFE);
ldProof.setProofValue(proofValue);
verifiableCredential.setAdditionalProperties("proof", ldProof);
return verifiableCredential;
} catch (IOException e) {
throw new SigningServiceException("Was not able to encode the signature.", e);
}
}
} }

View file

@ -17,15 +17,18 @@
package org.keycloak.protocol.oid4vc.issuance.signing; package org.keycloak.protocol.oid4vc.issuance.signing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException; import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider;
import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ConfigurationValidationHelper;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* Provider Factory to create {@link LDSigningService}s * Provider Factory to create {@link LDSigningService}s
@ -36,11 +39,15 @@ public class LDSigningServiceProviderFactory implements VCSigningServiceProvider
public static final Format SUPPORTED_FORMAT = Format.LDP_VC; public static final Format SUPPORTED_FORMAT = Format.LDP_VC;
private static final String HELP_TEXT = "Issues Verifiable Credentials in the W3C Data Model, using Linked-Data Proofs. See https://www.w3.org/TR/vc-data-model/"; private static final String HELP_TEXT = "Issues Verifiable Credentials in the W3C Data Model, using Linked-Data Proofs. See https://www.w3.org/TR/vc-data-model/";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override @Override
public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) { public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) {
String keyId = model.get(SigningProperties.KEY_ID.getKey()); String keyId = model.get(SigningProperties.KEY_ID.getKey());
String proofType = model.get(SigningProperties.PROOF_TYPE.getKey()); String proofType = model.get(SigningProperties.PROOF_TYPE.getKey());
return new LDSigningService(session, keyId, proofType); String algorithmType = model.get(SigningProperties.ALGORITHM_TYPE.getKey());
Optional<String> kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey()));
return new LDSigningService(session, keyId, algorithmType, proofType, OBJECT_MAPPER, new OffsetTimeProvider(), kid);
} }
@Override @Override
@ -51,7 +58,9 @@ public class LDSigningServiceProviderFactory implements VCSigningServiceProvider
@Override @Override
public List<ProviderConfigProperty> getConfigProperties() { public List<ProviderConfigProperty> getConfigProperties() {
return VCSigningServiceProviderFactory.configurationBuilder() return VCSigningServiceProviderFactory.configurationBuilder()
.property(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
.property(SigningProperties.PROOF_TYPE.asConfigProperty()) .property(SigningProperties.PROOF_TYPE.asConfigProperty())
.property(SigningProperties.KID_HEADER.asConfigProperty())
.build(); .build();
} }
@ -63,6 +72,7 @@ public class LDSigningServiceProviderFactory implements VCSigningServiceProvider
@Override @Override
public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
ConfigurationValidationHelper.check(model) ConfigurationValidationHelper.check(model)
.checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
.checkRequired(SigningProperties.PROOF_TYPE.asConfigProperty()); .checkRequired(SigningProperties.PROOF_TYPE.asConfigProperty());
} }

View file

@ -32,7 +32,6 @@ public enum SigningProperties {
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), 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), 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), 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"), 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"), HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"),

View file

@ -0,0 +1,137 @@
/*
* 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.protocol.oid4vc.issuance.signing.vcdm;
import com.apicatalog.jsonld.JsonLd;
import com.apicatalog.jsonld.JsonLdError;
import com.apicatalog.jsonld.document.JsonDocument;
import com.apicatalog.jsonld.http.DefaultHttpClient;
import com.apicatalog.jsonld.http.media.MediaType;
import com.apicatalog.jsonld.json.JsonUtils;
import com.apicatalog.jsonld.loader.HttpLoader;
import com.apicatalog.rdf.Rdf;
import com.apicatalog.rdf.RdfDataset;
import com.apicatalog.rdf.io.RdfWriter;
import com.apicatalog.rdf.io.error.RdfWriterException;
import com.apicatalog.rdf.io.error.UnsupportedContentException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.setl.rdf.normalization.RdfNormalize;
import jakarta.json.JsonObject;
import jakarta.json.JsonValue;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Optional;
/**
* Implementation of an LD-Crypto Suite for Ed25519Signature2018
* {@see https://w3c-ccg.github.io/ld-cryptosuite-registry/#ed25519signature2018}
* <p>
* Canonicalization Algorithm: https://w3id.org/security#URDNA2015
* Digest Algorithm: http://w3id.org/digests#sha256
* Signature Algorithm: http://w3id.org/security#ed25519
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class Ed255192018Suite implements LinkedDataCryptographicSuite {
private final ObjectMapper objectMapper;
private final SignatureSignerContext signerContext;
public static final String PROOF_TYPE = "Ed25519Signature2018";
public Ed255192018Suite(ObjectMapper objectMapper, SignatureSignerContext signerContext) {
this.objectMapper = objectMapper;
this.signerContext = signerContext;
}
@Override
public byte[] getSignature(VerifiableCredential verifiableCredential) {
byte[] transformedData = transform(verifiableCredential);
byte[] hashedData = digest(transformedData);
return sign(hashedData);
}
private byte[] transform(VerifiableCredential verifiableCredential) {
try {
String credentialString = objectMapper.writeValueAsString(verifiableCredential);
var credentialDocument = JsonDocument.of(new StringReader(credentialString));
var expandedDocument = JsonLd.expand(credentialDocument)
.loader(new HttpLoader(DefaultHttpClient.defaultInstance()))
.get();
Optional<JsonObject> documentObject = Optional.empty();
if (JsonUtils.isArray(expandedDocument)) {
documentObject = expandedDocument.asJsonArray().stream().filter(JsonUtils::isObject).map(JsonValue::asJsonObject).findFirst();
} else if (JsonUtils.isObject(expandedDocument)) {
documentObject = Optional.of(expandedDocument.asJsonObject());
}
if (documentObject.isPresent()) {
RdfDataset rdfDataset = JsonLd.toRdf(JsonDocument.of(documentObject.get())).get();
RdfDataset canonicalDataset = RdfNormalize.normalize(rdfDataset);
StringWriter writer = new StringWriter();
RdfWriter rdfWriter = Rdf.createWriter(MediaType.N_QUADS, writer);
rdfWriter.write(canonicalDataset);
return writer.toString()
.getBytes(StandardCharsets.UTF_8);
} else {
throw new SigningServiceException("Was not able to get the expanded json.");
}
} catch (JsonProcessingException e) {
throw new SigningServiceException("Was not able to serialize the credential", e);
} catch (JsonLdError e) {
throw new SigningServiceException("Was not able to create a JsonLD Document from the serialized string.", e);
} catch (UnsupportedContentException | IOException | RdfWriterException e) {
throw new SigningServiceException("Was not able to canonicalize the json-ld.", e);
}
}
private byte[] digest(byte[] transformedData) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest(transformedData);
} catch (NoSuchAlgorithmException e) {
throw new SigningServiceException("Algorithm SHA-256 not supported.", e);
}
}
private byte[] sign(byte[] hashData) {
return signerContext.sign(hashData);
}
@Override
public String getProofType() {
return PROOF_TYPE;
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.protocol.oid4vc.issuance.signing.vcdm;
/**
* Enum containing the w3c-registered Signature Suites
* {@see https://w3c-ccg.github.io/ld-cryptosuite-registry}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public enum LDSignatureType {
ED_25519_SIGNATURE_2018("Ed25519Signature2018"),
ED_25519_SIGNATURE_2020("Ed25519Signature2020"),
ECDSA_SECP_256K1_SIGNATURE_2019("EcdsaSecp256k1Signature2019"),
RSA_SIGNATURE_2018("RsaSignature2018"),
JSON_WEB_SIGNATURE_2020("JsonWebSignature2020"),
JCS_ED_25519_SIGNATURE_2020("JcsEd25519Signature2020");
private final String value;
LDSignatureType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static LDSignatureType getByValue(String value) {
for (LDSignatureType signatureType : values()) {
if (signatureType.getValue().equalsIgnoreCase(value))
return signatureType;
}
throw new IllegalArgumentException(String.format("No signature of type %s exists.", value));
}
}

View file

@ -0,0 +1,46 @@
/*
* 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.protocol.oid4vc.issuance.signing.vcdm;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
/**
* Interface for all implementations of LD-Signature Suites
* <p>
* {@see https://w3c-ccg.github.io/ld-cryptosuite-registry/}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public interface LinkedDataCryptographicSuite {
/**
* Return the signature for the given credential as defined by the suite.
*
* @param verifiableCredential the credential to create a signature for
* @return the signature
*/
byte[] getSignature(VerifiableCredential verifiableCredential);
/**
* The proof type defined by the suite.
*
* @return the type
*/
String getProofType();
}

View file

@ -0,0 +1,106 @@
/*
* 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.protocol.oid4vc.model.vcdm;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* Pojo to represent a linked-data proof
* {@see https://www.w3.org/TR/vc-data-model}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LdProof {
private String type;
private Date created;
private String proofPurpose;
private String verificationMethod;
private String proofValue;
private String jws;
@JsonIgnore
private Map<String, Object> additionalProperties = new HashMap<>();
@JsonAnyGetter
public Map<String, Object> getAdditionalProperties() {
return additionalProperties;
}
@JsonAnySetter
public void setAdditionalProperties(String name, Object property) {
additionalProperties.put(name, property);
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Date getCreated() {
return created;
}
public void setCreated(Date created) {
this.created = created;
}
public String getProofPurpose() {
return proofPurpose;
}
public void setProofPurpose(String proofPurpose) {
this.proofPurpose = proofPurpose;
}
public String getVerificationMethod() {
return verificationMethod;
}
public void setVerificationMethod(String verificationMethod) {
this.verificationMethod = verificationMethod;
}
public String getProofValue() {
return proofValue;
}
public void setProofValue(String proofValue) {
this.proofValue = proofValue;
}
public String getJws() {
return jws;
}
public void setJws(String jws) {
this.jws = jws;
}
}

View file

@ -172,6 +172,16 @@
<artifactId>jakarta.ws.rs-api</artifactId> <artifactId>jakarta.ws.rs-api</artifactId>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>com.apicatalog</groupId>
<artifactId>titanium-json-ld</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.setl</groupId>
<artifactId>rdf-urdna</artifactId>
<scope>provided</scope>
</dependency>
<!-- FIXME: Override in order to prevent NoClassDefFoundError: org/jboss/threads/AsyncFuture in ClientContainerController --> <!-- FIXME: Override in order to prevent NoClassDefFoundError: org/jboss/threads/AsyncFuture in ClientContainerController -->
<dependency> <dependency>

View file

@ -0,0 +1,191 @@
/*
* 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.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.signing.LDSigningService;
import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException;
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.idm.RealmRepresentation;
import org.keycloak.testsuite.runonserver.RunOnServerException;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class LDSigningServiceTest extends SigningServiceTest {
@Before
public void setup() {
CryptoIntegration.init(this.getClass().getClassLoader());
}
// If an unsupported algorithm is provided, the JWT Signing Service should not be instantiated.
@Test(expected = SigningServiceException.class)
public void testUnsupportedLdpType() throws Throwable {
try {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
new LDSigningService(
session,
getKeyFromSession(session).getKid(),
"EdDSA",
"UnsupportedSignatureType",
new ObjectMapper(),
new StaticTimeProvider(1000),
Optional.empty()));
} catch (RunOnServerException ros) {
throw ros.getCause();
}
}
// If no key is provided, the JWT Signing Service should not be instantiated.
@Test(expected = SigningServiceException.class)
public void testFailIfNoKey() throws Throwable {
try {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
new LDSigningService(
session,
"no-such-key",
"EdDSA",
"Ed25519Signature2018",
new ObjectMapper(),
new StaticTimeProvider(1000),
Optional.empty()));
} catch (RunOnServerException ros) {
throw ros.getCause();
}
}
// The provided credentials should be successfully signed as a JWT-VC.
@Test
public void testLdpSignedCredentialWithOutIssuanceDate() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignLdCredential(
session,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c")),
Optional.empty()));
}
@Test
public void testLdpSignedCredentialWithIssuanceDate() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignLdCredential(
session,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c"),
"issuanceDate", Date.from(Instant.ofEpochSecond(10))),
Optional.empty()));
}
@Test
public void testLdpSignedCredentialWithCustomKid() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignLdCredential(
session,
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
"test", "test",
"arrayClaim", List.of("a", "b", "c"),
"issuanceDate", Date.from(Instant.ofEpochSecond(10))),
Optional.of("did:web:test.org#the-key-id")));
}
@Test
public void testLdpSignedCredentialWithoutAdditionalClaims() {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session ->
testSignLdCredential(
session,
Map.of(),
Optional.empty()));
}
public static void testSignLdCredential(KeycloakSession session, Map<String, Object> claims, Optional<String> kid) {
KeyWrapper keyWrapper = getKeyFromSession(session);
LDSigningService ldSigningService = new LDSigningService(
session,
keyWrapper.getKid(),
"EdDSA",
"Ed25519Signature2018",
new ObjectMapper(),
new StaticTimeProvider(1000),
kid);
VerifiableCredential testCredential = getTestCredential(claims);
VerifiableCredential verifiableCredential = ldSigningService.signCredential(testCredential);
assertEquals("The types should be included", TEST_TYPES, verifiableCredential.getType());
assertEquals("The issuer should be included", TEST_DID, verifiableCredential.getIssuer());
assertNotNull("The context needs to be set.", verifiableCredential.getContext());
assertEquals("The expiration date should be included", TEST_EXPIRATION_DATE, verifiableCredential.getExpirationDate());
if (claims.containsKey("issuanceDate")) {
assertEquals("The issuance date should be included", claims.get("issuanceDate"), verifiableCredential.getIssuanceDate());
}
CredentialSubject subject = verifiableCredential.getCredentialSubject();
claims.entrySet().stream()
.filter(e -> !e.getKey().equals("issuanceDate"))
.forEach(e -> assertEquals(String.format("All additional claims should be set - %s is incorrect", e.getKey()), e.getValue(), subject.getClaims().get(e.getKey())));
assertNotNull("The credential should contain a signed proof.", verifiableCredential.getAdditionalProperties().get("proof"));
LdProof ldProof = (LdProof) verifiableCredential.getAdditionalProperties().get("proof");
String expectedKid = kid.orElse(keyWrapper.getKid());
assertEquals("The verification method should be set to the key id.", expectedKid, ldProof.getVerificationMethod());
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
if (testRealm.getComponents() != null) {
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getEdDSAKeyProvider());
} else {
testRealm.setComponents(new MultivaluedHashMap<>(
Map.of("org.keycloak.keys.KeyProvider", List.of(getEdDSAKeyProvider()))));
}
}
}

View file

@ -52,7 +52,6 @@ import java.util.UUID;
*/ */
public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest {
private static final Logger LOGGER = Logger.getLogger(SigningServiceTest.class);
protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1";
protected static final URI TEST_DID = URI.create("did:web:test.org"); protected static final URI TEST_DID = URI.create("did:web:test.org");
protected static final List<String> TEST_TYPES = List.of("VerifiableCredential"); protected static final List<String> TEST_TYPES = List.of("VerifiableCredential");
@ -164,7 +163,7 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest {
protected static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { protected static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) {
// we only set one key to the realm, thus can just take the first one // 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 // if run inside the testsuite, configure is called separated from the test itself, thus we cannot just take
// the key from the `configureTestRealm` method. // the key from the `configureTestRealm` method.
return keycloakSession return keycloakSession
.keys() .keys()
@ -173,6 +172,21 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest {
.orElseThrow(() -> new RuntimeException("No key was configured")); .orElseThrow(() -> new RuntimeException("No key was configured"));
} }
protected ComponentExportRepresentation getEdDSAKeyProvider() {
ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation();
componentExportRepresentation.setName("eddsa-generated");
componentExportRepresentation.setId(UUID.randomUUID().toString());
componentExportRepresentation.setProviderId("eddsa-generated");
componentExportRepresentation.setConfig(new MultivaluedHashMap<>(
Map.of(
"eddsaEllipticCurveKey", List.of("Ed25519"))
)
);
return componentExportRepresentation;
}
static class StaticTimeProvider implements TimeProvider { static class StaticTimeProvider implements TimeProvider {
private final int currentTimeInS; private final int currentTimeInS;