com.fasterxml.jackson.core
jackson-databind
diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
index 4d6d5e78..fe8aea49 100644
--- a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
+++ b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
@@ -13,12 +13,20 @@
import dev.learning.xapi.model.validation.constraints.ValidStatementRevision;
import dev.learning.xapi.model.validation.constraints.ValidStatementVerb;
import dev.learning.xapi.model.validation.constraints.Variant;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.lang.UnknownClassException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
+import java.net.URI;
+import java.security.PrivateKey;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import lombok.Builder;
@@ -127,6 +135,65 @@ public static class Builder {
// This static class extends the lombok builder.
+ /**
+ * Special build method for signing and building a {@link Statement}.
+ *
+ * An signature attachment is automatically added to the Statement's attachments.
+ *
+ *
+ * @param privateKey a {@link PrivateKey} for signing the {@link Statement}.
+ *
+ * @return an immutable, signed {@link Statement} object.
+ *
+ * @see
+ * Signed Statements
+ */
+ public Statement signAndBuild(PrivateKey privateKey) {
+ final Map claims = new HashMap<>();
+
+ // Put only the significant properties into the signature payload
+ // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements
+ claims.put("actor", this.actor);
+ claims.put("verb", this.verb);
+ claims.put("object", this.object);
+ claims.put("result", this.result);
+ claims.put("context", this.context);
+
+ try {
+ final var token = Jwts.builder().setClaims(claims)
+ .signWith(privateKey, SignatureAlgorithm.RS512).compact();
+
+ addAttachment(a -> a.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
+
+ .addDisplay(Locale.ENGLISH, "JSW signature")
+
+ .content(token)
+
+ .length(token.length())
+
+ .contentType("application/octet-stream"));
+
+ } catch (final UnknownClassException e) {
+ throw new IllegalStateException("""
+
+ Statement cannot be signed, because an optional dependency was NOT provided.
+ Please add the following dependencies into your project:
+
+
+ io.jsonwebtoken
+ jjwt-impl
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+
+ """, e);
+ }
+
+ return build();
+ }
+
/**
* Consumer Builder for agent.
*
diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
index 7b0bd86d..b83125a5 100644
--- a/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
+++ b/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
@@ -9,7 +9,10 @@
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.startsWith;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
@@ -19,11 +22,15 @@
import dev.learning.xapi.jackson.XapiStrictNullValuesModule;
import dev.learning.xapi.jackson.XapiStrictObjectTypeModule;
import dev.learning.xapi.jackson.XapiStrictTimestampModule;
+import io.jsonwebtoken.Jwts;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.io.IOException;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
@@ -41,6 +48,7 @@
* @author Lukáš Sahula
* @author Martin Myslik
* @author Thomas Turrell-Croft
+ * @author István Rátkai (Selindek)
*/
@DisplayName("Statement tests")
class StatementTests {
@@ -1286,4 +1294,336 @@ void whenDeserializingMinimalStatementWithAllTheModulesThenNoExceptionIsThrown()
}
+ @Test
+ void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorithmException {
+
+ final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ final var keyPair = keyPairGenerator.generateKeyPair();
+
+ // When Signing Statement
+ final var extensions = new LinkedHashMap();
+ extensions.put(URI.create("http://name"), "Kilby");
+
+ final var account = Account.builder()
+
+ .homePage(URI.create("https://example.com"))
+
+ .name("13936749")
+
+ .build();
+
+
+ final var statement = Statement.builder()
+
+ .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1"))
+
+ .actor(a -> a.name("A N Other"))
+
+ .verb(v -> v.id(URI.create("http://example.com/xapi/verbs#sent-a-statement"))
+ .addDisplay(Locale.US, "attended"))
+
+ .result(r -> r.success(true).completion(true).response("Response").duration("P1D"))
+
+ .context(c -> c
+
+ .registration(UUID.fromString("ec531277-b57b-4c15-8d91-d292c5b2b8f7"))
+
+ .agentInstructor(a -> a.name("A N Other").account(account))
+
+ .team(t -> t.name("Team").mbox("mailto:team@example.com"))
+
+ .platform("Example virtual meeting software")
+
+ .language(Locale.ENGLISH)
+
+ .statementReference(s -> s.id(UUID.fromString("6690e6c9-3ef0-4ed3-8b37-7f3964730bee")))
+
+ )
+
+ .timestamp(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .stored(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .agentAuthority(a -> a.account(account))
+
+ .activityObject(a -> a.id("http://www.example.com/meetings/occurances/34534")
+
+ .definition(d -> d.addName(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .addDescription(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .type(URI.create("http://adlnet.gov/expapi/activities/meeting"))
+
+ .moreInfo(URI.create("http://virtualmeeting.example.com/345256"))
+
+ .extensions(extensions)))
+
+ .version("1.0.0")
+
+ .signAndBuild(keyPair.getPrivate());
+
+ // Then Signature is Added As Attachment
+ assertThat(statement.getAttachments(), hasSize(1));
+
+ }
+
+ @Test
+ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment()
+ throws NoSuchAlgorithmException {
+
+ final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ final var keyPair = keyPairGenerator.generateKeyPair();
+
+ // When Signing Statement
+ final var extensions = new LinkedHashMap();
+ extensions.put(URI.create("http://name"), "Kilby");
+
+ final var attachment = Attachment.builder().usageType(URI.create("http://example.com"))
+ .fileUrl(URI.create("http://example.com"))
+
+ .addDisplay(Locale.ENGLISH, "value")
+
+ .addDescription(Locale.ENGLISH, "value")
+
+ .length(123)
+
+ .sha2("123")
+
+ .contentType("file")
+
+ .build();
+
+ final var account = Account.builder()
+
+ .homePage(URI.create("https://example.com"))
+
+ .name("13936749")
+
+ .build();
+
+
+ final var statement = Statement.builder()
+
+ .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1"))
+
+ .actor(a -> a.name("A N Other"))
+
+ .verb(v -> v.id(URI.create("http://example.com/xapi/verbs#sent-a-statement"))
+ .addDisplay(Locale.US, "attended"))
+
+ .result(r -> r.success(true).completion(true).response("Response").duration("P1D"))
+
+ .context(c -> c
+
+ .registration(UUID.fromString("ec531277-b57b-4c15-8d91-d292c5b2b8f7"))
+
+ .agentInstructor(a -> a.name("A N Other").account(account))
+
+ .team(t -> t.name("Team").mbox("mailto:team@example.com"))
+
+ .platform("Example virtual meeting software")
+
+ .language(Locale.ENGLISH)
+
+ .statementReference(s -> s.id(UUID.fromString("6690e6c9-3ef0-4ed3-8b37-7f3964730bee")))
+
+ )
+
+ .timestamp(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .stored(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .agentAuthority(a -> a.account(account))
+
+ .activityObject(a -> a.id("http://www.example.com/meetings/occurances/34534")
+
+ .definition(d -> d.addName(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .addDescription(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .type(URI.create("http://adlnet.gov/expapi/activities/meeting"))
+
+ .moreInfo(URI.create("http://virtualmeeting.example.com/345256"))
+
+ .extensions(extensions)))
+
+ .addAttachment(attachment)
+
+ .version("1.0.0")
+
+ .signAndBuild(keyPair.getPrivate());
+
+ // Then Signature is Added As Attachment
+ assertThat(statement.getAttachments(), hasSize(2));
+
+ }
+
+ @Test
+ void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmException {
+
+ final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ final var keyPair = keyPairGenerator.generateKeyPair();
+
+ // When Signing Statement
+ final var extensions = new LinkedHashMap();
+ extensions.put(URI.create("http://name"), "Kilby");
+
+ final var account = Account.builder()
+
+ .homePage(URI.create("https://example.com"))
+
+ .name("13936749")
+
+ .build();
+
+
+ final var statement = Statement.builder()
+
+ .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1"))
+
+ .actor(a -> a.name("A N Other"))
+
+ .verb(v -> v.id(URI.create("http://example.com/xapi/verbs#sent-a-statement"))
+ .addDisplay(Locale.US, "attended"))
+
+ .result(r -> r.success(true).completion(true).response("Response").duration("P1D"))
+
+ .context(c -> c
+
+ .registration(UUID.fromString("ec531277-b57b-4c15-8d91-d292c5b2b8f7"))
+
+ .agentInstructor(a -> a.name("A N Other").account(account))
+
+ .team(t -> t.name("Team").mbox("mailto:team@example.com"))
+
+ .platform("Example virtual meeting software")
+
+ .language(Locale.ENGLISH)
+
+ .statementReference(s -> s.id(UUID.fromString("6690e6c9-3ef0-4ed3-8b37-7f3964730bee")))
+
+ )
+
+ .timestamp(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .stored(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .agentAuthority(a -> a.account(account))
+
+ .activityObject(a -> a.id("http://www.example.com/meetings/occurances/34534")
+
+ .definition(d -> d.addName(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .addDescription(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .type(URI.create("http://adlnet.gov/expapi/activities/meeting"))
+
+ .moreInfo(URI.create("http://virtualmeeting.example.com/345256"))
+
+ .extensions(extensions)))
+
+ .version("1.0.0")
+
+ .signAndBuild(keyPair.getPrivate());
+
+ // Then Signature is Expected
+ assertThat(new String(statement.getAttachments().get(0).getContent(), StandardCharsets.UTF_8),
+ startsWith(
+ "eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuLVVTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW4tR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuLUdCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19."));
+
+ }
+
+ @Test
+ void whenSigningStatementThenSignatureIsValid()
+ throws NoSuchAlgorithmException, JsonMappingException, JsonProcessingException {
+
+ final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ final var keyPair = keyPairGenerator.generateKeyPair();
+
+ // When Signing Statement
+ final var extensions = new LinkedHashMap();
+ extensions.put(URI.create("http://name"), "Kilby");
+
+ final var account = Account.builder()
+
+ .homePage(URI.create("https://example.com"))
+
+ .name("13936749")
+
+ .build();
+
+
+ final var statement = Statement.builder()
+
+ .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1"))
+
+ .actor(a -> a.name("A N Other"))
+
+ .verb(v -> v.id(URI.create("http://example.com/xapi/verbs#sent-a-statement"))
+ .addDisplay(Locale.US, "attended"))
+
+ .result(r -> r.success(true).completion(true).response("Response").duration("P1D"))
+
+ .context(c -> c
+
+ .registration(UUID.fromString("ec531277-b57b-4c15-8d91-d292c5b2b8f7"))
+
+ .agentInstructor(a -> a.name("A N Other").account(account))
+
+ .team(t -> t.name("Team").mbox("mailto:team@example.com"))
+
+ .platform("Example virtual meeting software")
+
+ .language(Locale.ENGLISH)
+
+ .statementReference(s -> s.id(UUID.fromString("6690e6c9-3ef0-4ed3-8b37-7f3964730bee")))
+
+ )
+
+ .timestamp(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .stored(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .agentAuthority(a -> a.account(account))
+
+ .activityObject(a -> a.id("http://www.example.com/meetings/occurances/34534")
+
+ .definition(d -> d.addName(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .addDescription(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .type(URI.create("http://adlnet.gov/expapi/activities/meeting"))
+
+ .moreInfo(URI.create("http://virtualmeeting.example.com/345256"))
+
+ .extensions(extensions)))
+
+ .version("1.0.0")
+
+ .signAndBuild(keyPair.getPrivate());
+
+ // Then Signature is Valid
+ final var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build()
+ .parseClaimsJws(
+ new String(statement.getAttachments().get(0).getContent(), StandardCharsets.UTF_8))
+ .getBody();
+
+ final var bodyStatement =
+ objectMapper.readValue(objectMapper.writeValueAsString(body), Statement.class);
+
+ assertThat(bodyStatement, is(statement));
+
+ }
}