closes #25943 Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>
This commit is contained in:
parent
9fb766bbce
commit
67d3e1e467
13 changed files with 660 additions and 8 deletions
15
pom.xml
15
pom.xml
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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()))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue