Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multiple subjects in a Verifiable Credential #33

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@
import com.danubetech.verifiablecredentials.jsonld.VerifiableCredentialContexts;
import com.danubetech.verifiablecredentials.jsonld.VerifiableCredentialKeywords;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import foundation.identity.jsonld.ConfigurableDocumentLoader;
import foundation.identity.jsonld.JsonLDObject;
import foundation.identity.jsonld.JsonLDUtils;
import info.weboftrust.ldsignatures.LdProof;
import jakarta.json.JsonArray;
import jakarta.json.JsonObject;

import javax.security.auth.login.CredentialException;
import java.io.Reader;
import java.net.URI;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class VerifiableCredential extends JsonLDObject {

Expand All @@ -21,6 +31,8 @@ public class VerifiableCredential extends JsonLDObject {
public static final String DEFAULT_JSONLD_PREDICATE = VerifiableCredentialKeywords.JSONLD_TERM_VERIFIABLECREDENTIAL;
public static final DocumentLoader DEFAULT_DOCUMENT_LOADER = VerifiableCredentialContexts.DOCUMENT_LOADER;

private static final ObjectMapper objectMapper = new ObjectMapper();

@JsonCreator
public VerifiableCredential() {
super();
Expand All @@ -39,12 +51,13 @@ public static class Builder<B extends Builder<B>> extends JsonLDObject.Builder<B
private URI issuer;
private Date issuanceDate;
private Date expirationDate;
private CredentialSubject credentialSubject;
private List<CredentialSubject> credentialSubjects;
private CredentialStatus credentialStatus;
private LdProof ldProof;

public Builder(VerifiableCredential jsonLdObject) {
super(jsonLdObject);
this.credentialSubjects = new ArrayList<>();
this.forceContextsArray(true);
this.forceTypesArray(true);
this.defaultContexts(true);
Expand All @@ -60,7 +73,13 @@ public VerifiableCredential build() {
if (this.issuer != null) JsonLDUtils.jsonLdAdd(this.jsonLdObject, VerifiableCredentialKeywords.JSONLD_TERM_ISSUER, JsonLDUtils.uriToString(this.issuer));
if (this.issuanceDate != null) JsonLDUtils.jsonLdAdd(this.jsonLdObject, VerifiableCredentialKeywords.JSONLD_TERM_ISSUANCEDATE, JsonLDUtils.dateToString(this.issuanceDate));
if (this.expirationDate != null) JsonLDUtils.jsonLdAdd(this.jsonLdObject, VerifiableCredentialKeywords.JSONLD_TERM_EXPIRATIONDATE, JsonLDUtils.dateToString(this.expirationDate));
if (this.credentialSubject != null) this.credentialSubject.addToJsonLDObject(this.jsonLdObject);
if (this.credentialSubjects != null) {
if (this.credentialSubjects.size() == 1) {
this.credentialSubjects.get(0).addToJsonLDObject(this.jsonLdObject);
} else {
JsonLDUtils.jsonLdAddAsJsonArray(this.jsonLdObject, VerifiableCredentialKeywords.JSONLD_TERM_CREDENTIALSUBJECT, this.credentialSubjects);
}
}
if (this.credentialStatus != null) this.credentialStatus.addToJsonLDObject(this.jsonLdObject);
if (this.ldProof != null) this.ldProof.addToJsonLDObject(this.jsonLdObject);

Expand All @@ -83,7 +102,12 @@ public B expirationDate(Date expirationDate) {
}

public B credentialSubject(CredentialSubject credentialSubject) {
this.credentialSubject = credentialSubject;
this.credentialSubjects.add(credentialSubject);
return (B) this;
}

public B credentialSubjects(List<CredentialSubject> credentialSubjects) {
this.credentialSubjects = credentialSubjects;
return (B) this;
}

Expand Down Expand Up @@ -149,7 +173,39 @@ public Date getExpirationDate() {
}

public CredentialSubject getCredentialSubject() {
return CredentialSubject.getFromJsonLDObject(this);
return getCredentialSubjects().get(0);
}

/**
* Get a list of all subjects contained in the receiver.
*
* @return A list of each subject in the "credentialSubject" property as `CredentialSubject` objects.
*/
public List<CredentialSubject> getCredentialSubjects() {
/*
The "credentialSubject" node may contain an array of objects as permissible in the VC spec:
https://www.w3.org/TR/vc-data-model/#credential-subject
*/
Object entry = this.getJsonObject().get(VerifiableCredentialKeywords.JSONLD_TERM_CREDENTIALSUBJECT);
if (entry == null) {
return new ArrayList(); // empty list
} else if (entry instanceof List) {
final TypeReference<Map<String, Object>> stringMapTypeReference = new TypeReference<Map<String, Object>>() {};

final List<Object> subjectArray = JsonLDUtils.jsonLdGetJsonArray(this.getJsonObject(), VerifiableCredentialKeywords.JSONLD_TERM_CREDENTIALSUBJECT);

// Convert each object in the list to a CredentialSubject
return subjectArray.stream()
.map(subjectObject -> objectMapper.convertValue(subjectObject, stringMapTypeReference))
.map(subjectMap -> CredentialSubject.fromJsonObject(subjectMap))
.collect(Collectors.toList());
} else {
final Map<String, Object> subjectObject = JsonLDUtils.jsonLdGetJsonObject(this.getJsonObject(), VerifiableCredentialKeywords.JSONLD_TERM_CREDENTIALSUBJECT);
final CredentialSubject singleSubject = CredentialSubject.fromJsonObject(subjectObject);
return new ArrayList<CredentialSubject>() {{
add(singleSubject);
}};
}
}

public LdProof getLdProof() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ public static JwtVerifiableCredential toJwtVerifiableCredential(VerifiableCreden

CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject();
if (credentialSubject != null) {
if (verifiableCredential.getCredentialSubjects().size() > 1) {
/*
Per https://www.w3.org/TR/vc-data-model/#jwt-encoding:

Implementers are warned that JWTs are not capable of encoding multiple subjects and are thus not capable of
encoding a verifiable credential with more than one subject. JWTs might support multiple subjects in the
future and implementers are advised to refer to the JSON Web Token Claim Registry for multi-subject JWT
claim names or the Nested JSON Web Token specification.
*/
throw new IllegalArgumentException("JWTs are not capable of encoding multiple subjects and are thus not capable of encoding a verifiable credential with more than one subject.");
}

if (credentialSubject.getId() != null) {
jwtPayloadBuilder.subject(credentialSubject.getId().toString());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static void validate(VerifiableCredential verifiableCredential) throws Il
validateRun(() -> { validateTrue(verifiableCredential.getIssuanceDate() != null); }, "Bad or missing 'issuanceDate'.");
validateRun(() -> { verifiableCredential.getExpirationDate(); }, "Bad 'expirationDate'.");
validateRun(() -> { verifiableCredential.getCredentialSubject(); }, "Bad 'credentialSubject'.");
validateRun(() -> { validateTrue(verifiableCredential.getCredentialSubjects().size() > 0); }, "Bad or missing 'credentialSubject'");
validateRun(() -> { validateTrue(verifiableCredential.getCredentialSubject() != null); }, "Bad or missing 'credentialSubject'.");
}

Expand Down
46 changes: 46 additions & 0 deletions src/test/java/com/danubetech/verifiablecredentials/JwtTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

import java.net.URI;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;
Expand Down Expand Up @@ -59,6 +61,50 @@ void testSign() throws Exception {
assertEquals(TestUtil.read(VerifyCredentialTest.class.getResourceAsStream("jwt.payload.vc.jsonld")).trim(), jwtPayload.trim());
}

@Test
void testSignMultiSubjectUnsupported() throws Exception {

Map<String, Object> firstClaims = new LinkedHashMap<>();
Map<String, Object> firstDriversLicense = new LinkedHashMap<String, Object>();
firstDriversLicense.put("licenseClass", "trucks");
firstDriversLicense.put("suspended", Boolean.FALSE);
firstClaims.put("name", "M S");
firstClaims.put("driversLicense", firstDriversLicense);

CredentialSubject firstCredentialSubject = CredentialSubject.builder()
.id(URI.create("did:sov:21tDAKCERh95uGgKbJNHYp"))
.claims(firstClaims)
.build();

Map<String, Object> secondClaims = new LinkedHashMap<>();
Map<String, Object> secondDriversLicense = new LinkedHashMap<String, Object>();
secondDriversLicense.put("licenseClass", "motorcycles");
secondDriversLicense.put("suspended", Boolean.TRUE);
secondClaims.put("name", "M S");
secondClaims.put("driversLicense", secondDriversLicense);

CredentialSubject secondCredentialSubject = CredentialSubject.builder()
.id(URI.create("did:sov:21tDAKCERh95uGgKbJNHYq"))
.claims(secondClaims)
.build();

VerifiableCredential verifiableCredential = VerifiableCredential.builder()
.context(URI.create("https://trafi.fi/credentials/v1"))
.type("DriversLicenseCredential")
.id(URI.create("urn:uuid:a87bdfb8-a7df-4bd9-ae0d-d883133538fe"))
.issuer(URI.create("did:sov:1yvXbmgPoUm4dl66D7KhyD"))
.issuanceDate(JsonLDUtils.stringToDate("2019-06-16T18:56:59Z"))
.expirationDate(JsonLDUtils.stringToDate("2019-06-17T18:56:59Z"))
.credentialSubjects(new ArrayList<CredentialSubject>() {{
add(firstCredentialSubject);
add(secondCredentialSubject);
}})
.build();

IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { ToJwtConverter.toJwtVerifiableCredential(verifiableCredential); });
assertEquals("JWTs are not capable of encoding multiple subjects and are thus not capable of encoding a verifiable credential with more than one subject.", exception.getMessage());
}

@Test
void testVerify() throws Exception {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,35 @@ void testSign() throws Throwable {
boolean verify = verifier.verify(verifiableCredential);
assertTrue(verify);
}

@Test
void testSignMultiSubject() throws Throwable {

VerifiableCredential verifiableCredential = VerifiableCredential.fromJson(new InputStreamReader(VerifyCredentialTest.class.getResourceAsStream("input.multisubject.vc.jsonld")));

URI verificationMethod = URI.create("did:sov:1yvXbmgPoUm4dl66D7KhyD#keys-1");
Date created = JsonLDUtils.DATE_FORMAT.parse("2018-01-01T21:19:10Z");
String domain = null;
String nonce = "c0ae1c8e-c7e7-469f-b252-86e6a0e7387e";

RsaSignature2018LdSigner signer = new RsaSignature2018LdSigner(TestUtil.testRSAPrivateKey);
signer.setVerificationMethod(verificationMethod);
signer.setCreated(created);
signer.setDomain(domain);
signer.setNonce(nonce);
LdProof ldProof = signer.sign(verifiableCredential, true, false);

assertEquals(SignatureSuites.SIGNATURE_SUITE_RSASIGNATURE2018.getTerm(), ldProof.getType());
assertEquals(verificationMethod, ldProof.getVerificationMethod());
assertEquals(created, ldProof.getCreated());
assertEquals(domain, ldProof.getDomain());
assertEquals(nonce, ldProof.getNonce());
assertEquals("eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJSUzI1NiJ9..ftxWowm3x1roIwnkdOK8ARWtPzYh9WPmR4QAxnZy2kBA_NgePpqtlIek1lMshx4Syv6N9CJqEXDFeURRNsQWAhhiGYAts_PphIZtrpeXuEh_dnMcyRhO2iFNQby5hwhLRwo_HeuNop73oiJYqFm_P_3_F0NJaKrONQ5dsiM3M2SNwInKVhuZ_x-vOqEX3BDcajBJ0wHn9CCbysynhWmxzLiZ4HxQW3aBWG94GmCQBJVc9pP8H4B58-jwDhz_L74rl5iywH5NiaokBggrBbJcz2QJBMYuYEjsAO6UQAm-T5EJcVzDoy0qxlmxgSaDovbr-xHGU8Wjc-cROt8Kqc1lqw", ldProof.getJws());

Validation.validate(verifiableCredential);

RsaSignature2018LdVerifier verifier = new RsaSignature2018LdVerifier(TestUtil.testRSAPublicKey);
boolean verify = verifier.verify(verifiableCredential);
assertTrue(verify);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,35 @@ void testSign() throws Throwable {
boolean verify = verifier.verify(verifiablePresentation);
assertTrue(verify);
}

@Test
void testMultiSubjectSign() throws Throwable {

VerifiablePresentation verifiablePresentation = VerifiablePresentation.fromJson(new InputStreamReader(VerifyCredentialTest.class.getResourceAsStream("input.multisubject.vp.jsonld")));

URI verificationMethod = URI.create("did:sov:1yvXbmgPoUm4dl66D7KhyD#keys-1");
Date created = JsonLDUtils.DATE_FORMAT.parse("2018-01-01T21:19:10Z");
String domain = null;
String nonce = "c0ae1c8e-c7e7-469f-b252-86e6a0e7387e";

RsaSignature2018LdSigner signer = new RsaSignature2018LdSigner(TestUtil.testRSAPrivateKey);
signer.setVerificationMethod(verificationMethod);
signer.setCreated(created);
signer.setDomain(domain);
signer.setNonce(nonce);
LdProof ldSignature = signer.sign(verifiablePresentation, true, false);

assertEquals(SignatureSuites.SIGNATURE_SUITE_RSASIGNATURE2018.getTerm(), ldSignature.getType());
assertEquals(verificationMethod, ldSignature.getVerificationMethod());
assertEquals(created, ldSignature.getCreated());
assertEquals(domain, ldSignature.getDomain());
assertEquals(nonce, ldSignature.getNonce());
assertEquals("eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJSUzI1NiJ9..RNCMCgkZDI0gAQVXjG2LlJuAIFMv5ULpLE0u8dIryRTaqmLgx6gKXOTmr44qqIT2DmuMt_6guUXckXwKnRhBxHd0UP31QjCIK-DEJkNxOlccXqQUAxOj8pbS2IIrBh_a0lWeOxDrLRnLriqeLFvc_ihOOCkh45dTdCE7yhcf1QYX2Bn3-iZJlfLqHShI7tKC1n0S9-2f_KbOOLzfghY5pSEEPn-6QKQPhVSx0_G6t0zQXU6ti1eTx0Mx1E3qQ92eCrnxYkrLpDdhzvUCdh_vl8DwYzPnFYECZAkQxLKdeRmoISoiXhxQekhPPhz4JqJajBhpabTof1wv7qGwyw6qow", ldSignature.getJws());

Validation.validate(verifiablePresentation);

RsaSignature2018LdVerifier verifier = new RsaSignature2018LdVerifier(TestUtil.testRSAPublicKey);
boolean verify = verifier.verify(verifiablePresentation);
assertTrue(verify);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.danubetech.verifiablecredentials;

import com.danubetech.verifiablecredentials.validation.Validation;
import foundation.identity.jsonld.JsonLDException;
import info.weboftrust.ldsignatures.verifier.RsaSignature2018LdVerifier;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.InputStreamReader;
import java.security.GeneralSecurityException;
import java.util.List;
import java.util.Set;

import static org.junit.jupiter.api.Assertions.*;

Expand All @@ -26,6 +31,36 @@ void testVerify() throws Throwable {
String givenName = credentialSubject == null ? null : (String) credentialSubject.getClaims().get("givenName");

assertEquals("Manu", givenName);

// Credential subject as array has one entry
List<CredentialSubject> credentialSubjects = verifiableCredential.getCredentialSubjects();

assertEquals(1, credentialSubjects.size());
assertEquals(credentialSubject, credentialSubjects.get(0));
}

@Test
void testMultiSubjectVerify() throws Throwable {

VerifiableCredential verifiableCredential = VerifiableCredential.fromJson(new InputStreamReader(VerifyCredentialTest.class.getResourceAsStream("signed.good.multisubject.vc.jsonld")));

Validation.validate(verifiableCredential);

RsaSignature2018LdVerifier verifier = new RsaSignature2018LdVerifier(TestUtil.testRSAPublicKey);
boolean verify = verifier.verify(verifiableCredential);

assertTrue(verify);

// First credential subject still available
CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject();
String givenName = credentialSubject == null ? null : (String) credentialSubject.getClaims().get("givenName");

assertEquals("Manu", givenName);

// Multiple credential subjects also available
List<CredentialSubject> credentialSubjects = verifiableCredential.getCredentialSubjects();

assertEquals(2, credentialSubjects.size());
}

@Test
Expand Down
Loading