From 67d3e1e467a68c315e253d5b3058a16908d1e11a Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Mon, 18 Mar 2024 17:05:53 +0100 Subject: [PATCH] Issue Verifiable Credentials in the VCDM format #25943 (#27071) closes #25943 Signed-off-by: Stefan Wiedemann --- pom.xml | 15 ++ quarkus/runtime/pom.xml | 9 + services/pom.xml | 10 + .../issuance/signing/LDSigningService.java | 61 +++++- .../LDSigningServiceProviderFactory.java | 12 +- .../issuance/signing/SigningProperties.java | 1 - .../signing/vcdm/Ed255192018Suite.java | 137 +++++++++++++ .../signing/vcdm/LDSignatureType.java | 52 +++++ .../vcdm/LinkedDataCryptographicSuite.java | 46 +++++ .../protocol/oid4vc/model/vcdm/LdProof.java | 106 ++++++++++ .../integration-arquillian/tests/base/pom.xml | 10 + .../signing/LDSigningServiceTest.java | 191 ++++++++++++++++++ .../issuance/signing/SigningServiceTest.java | 18 +- 13 files changed, 660 insertions(+), 8 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/Ed255192018Suite.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LDSignatureType.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LinkedDataCryptographicSuite.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/vcdm/LdProof.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java diff --git a/pom.xml b/pom.xml index 8828d83fe4..8d5f590c74 100644 --- a/pom.xml +++ b/pom.xml @@ -146,6 +146,10 @@ 3.4.0 2.3.32 + + 1.3.3 + 1.1 + ${jetty94.version} 4.25.1 @@ -1664,6 +1668,17 @@ test ${version.org.wildfly.glow} + + + com.apicatalog + titanium-json-ld + ${com.apicatalog.titanium-json-ld.version} + + + io.setl + rdf-urdna + ${io.setl.rdf-urdna.version} + diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 0b5cf28e07..67a91f3719 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -100,6 +100,15 @@ quarkus-logging-json + + com.apicatalog + titanium-json-ld + + + io.setl + rdf-urdna + + io.smallrye.config diff --git a/services/pom.xml b/services/pom.xml index 42fe00efb8..16803558fb 100755 --- a/services/pom.xml +++ b/services/pom.xml @@ -187,6 +187,16 @@ commons-io provided + + com.apicatalog + titanium-json-ld + provided + + + io.setl + rdf-urdna + provided + junit junit diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java index 71bc77bfb7..1feb7a6c0b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java @@ -18,8 +18,21 @@ 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.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.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, @@ -31,16 +44,56 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; */ public class LDSigningService extends SigningService { + private final LinkedDataCryptographicSuite linkedDataCryptographicSuite; + private final TimeProvider timeProvider; + private final String keyId; - public LDSigningService(KeycloakSession keycloakSession, String keyId, String ldpType) { - super(keycloakSession, keyId, ldpType); + public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, ObjectMapper objectMapper, TimeProvider timeProvider, Optional kid) { + 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 public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { - - throw new UnsupportedOperationException("LD-Credentials Signing is not yet supported."); + return addProof(verifiableCredential); } + // 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); + } + } } \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningServiceProviderFactory.java index 6ebb156b17..71cd5bf507 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningServiceProviderFactory.java @@ -17,15 +17,18 @@ package org.keycloak.protocol.oid4vc.issuance.signing; +import com.fasterxml.jackson.databind.ObjectMapper; 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.model.Format; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; import java.util.List; +import java.util.Optional; /** * 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; 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 public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) { String keyId = model.get(SigningProperties.KEY_ID.getKey()); String proofType = model.get(SigningProperties.PROOF_TYPE.getKey()); - return new LDSigningService(session, keyId, proofType); + String algorithmType = model.get(SigningProperties.ALGORITHM_TYPE.getKey()); + Optional kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey())); + return new LDSigningService(session, keyId, algorithmType, proofType, OBJECT_MAPPER, new OffsetTimeProvider(), kid); } @Override @@ -51,7 +58,9 @@ public class LDSigningServiceProviderFactory implements VCSigningServiceProvider @Override public List getConfigProperties() { return VCSigningServiceProviderFactory.configurationBuilder() + .property(SigningProperties.ALGORITHM_TYPE.asConfigProperty()) .property(SigningProperties.PROOF_TYPE.asConfigProperty()) + .property(SigningProperties.KID_HEADER.asConfigProperty()) .build(); } @@ -63,6 +72,7 @@ public class LDSigningServiceProviderFactory implements VCSigningServiceProvider @Override public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { ConfigurationValidationHelper.check(model) + .checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty()) .checkRequired(SigningProperties.PROOF_TYPE.asConfigProperty()); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java index e115a189b6..6a6c863fe6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java @@ -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), 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), HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"), diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/Ed255192018Suite.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/Ed255192018Suite.java new file mode 100644 index 0000000000..decce22fef --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/Ed255192018Suite.java @@ -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} + *

+ * Canonicalization Algorithm: https://w3id.org/security#URDNA2015 + * Digest Algorithm: http://w3id.org/digests#sha256 + * Signature Algorithm: http://w3id.org/security#ed25519 + * + * @author Stefan Wiedemann + */ +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 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; + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LDSignatureType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LDSignatureType.java new file mode 100644 index 0000000000..d80559a9a5 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LDSignatureType.java @@ -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 Stefan Wiedemann + */ +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)); + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LinkedDataCryptographicSuite.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LinkedDataCryptographicSuite.java new file mode 100644 index 0000000000..288e126e8d --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/vcdm/LinkedDataCryptographicSuite.java @@ -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 + *

+ * {@see https://w3c-ccg.github.io/ld-cryptosuite-registry/} + * + * @author Stefan Wiedemann + */ +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(); + +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/vcdm/LdProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/vcdm/LdProof.java new file mode 100644 index 0000000000..cd72df0730 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/vcdm/LdProof.java @@ -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 Stefan Wiedemann + */ +@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 additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map 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; + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/pom.xml b/testsuite/integration-arquillian/tests/base/pom.xml index 2b0db69646..3b5a0cbf00 100644 --- a/testsuite/integration-arquillian/tests/base/pom.xml +++ b/testsuite/integration-arquillian/tests/base/pom.xml @@ -172,6 +172,16 @@ jakarta.ws.rs-api provided + + com.apicatalog + titanium-json-ld + provided + + + io.setl + rdf-urdna + provided + diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java new file mode 100644 index 0000000000..12e3b04a7d --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java @@ -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 claims, Optional 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())))); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java index 19bd22e0b2..423be5208a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java @@ -52,7 +52,6 @@ import java.util.UUID; */ 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 URI TEST_DID = URI.create("did:web:test.org"); protected static final List TEST_TYPES = List.of("VerifiableCredential"); @@ -164,7 +163,7 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { 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 + // if run inside the testsuite, configure is called separated from the test itself, thus we cannot just take // the key from the `configureTestRealm` method. return keycloakSession .keys() @@ -173,6 +172,21 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { .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 { private final int currentTimeInS;