add iron for vc verification, refactor+cleanup

This commit is contained in:
Markus Gylling 2022-07-08 11:33:45 +02:00
parent 092d4eb8f1
commit 9d3110547e
26 changed files with 923 additions and 660 deletions

View File

@ -1,20 +1,17 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.1edtech</groupId>
<artifactId>inspector</artifactId>
<version>0.9.2</version>
</parent>
<artifactId>inspector-vc</artifactId>
<dependencies>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.1edtech</groupId>
<artifactId>inspector</artifactId>
<version>0.9.2</version>
</parent>
<artifactId>inspector-vc</artifactId>
<dependencies>
<dependency>
<groupId>org.1edtech</groupId>
<artifactId>inspector-core</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>1.65</version>
<artifactId>inspector-core</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
@ -32,17 +29,10 @@
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.0.12.RELEASE</version>
<groupId>com.apicatalog</groupId>
<artifactId>iron-verifiable-credentials-jre8</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>com.google</groupId>
<artifactId>bitcoinj</artifactId>
<version>0.11.3</version>
</dependency>
<!-- Thanks for using https://jar-download.com -->
<!-- https://mvnrepository.com/artifact/com.apicatalog/titanium-json-ld -->
<dependency>
<groupId>com.apicatalog</groupId>
@ -50,7 +40,7 @@
<version>1.3.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.setl/rdf-urdna -->
<!-- https://github.com/setl/rdf-urdna -->
<!-- https://github.com/setl/rdf-urdna
<dependency>
<groupId>io.setl</groupId>
<artifactId>rdf-urdna</artifactId>
@ -65,7 +55,8 @@
<artifactId>titanium-json-ld</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependency>
-->
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.json</artifactId>

View File

@ -4,6 +4,7 @@ import static org.oneedtech.inspect.util.code.Defensives.*;
import static org.oneedtech.inspect.util.resource.ResourceType.*;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import org.oneedtech.inspect.core.probe.GeneratedObject;
@ -29,18 +30,20 @@ public class Credential extends GeneratedObject {
public Credential(Resource resource, JsonNode data, String jwt) {
super(ID, GeneratedObject.Type.INTERNAL);
checkNotNull(resource, resource.getType(), data);
ResourceType type = resource.getType();
checkTrue(type == SVG || type == PNG || type == JSON || type == JWT,
"Unrecognized payload type: " + type.getName());
this.resource = resource;
this.jsonData = data;
this.jwt = jwt;
ArrayNode typeNode = (ArrayNode)jsonData.get("type");
this.resource = checkNotNull(resource);
this.jsonData = checkNotNull(data);
this.jwt = jwt; //may be null
checkTrue(RECOGNIZED_PAYLOAD_TYPES.contains(resource.getType()));
ArrayNode typeNode = (ArrayNode)jsonData.get("type");
this.credentialType = Credential.Type.valueOf(typeNode);
}
public Credential(Resource resource, JsonNode data) {
this(resource, data, null);
}
public Resource getResource() {
return resource;
}
@ -53,8 +56,8 @@ public class Credential extends GeneratedObject {
return credentialType;
}
public String getJwt() {
return jwt;
public Optional<String> getJwt() {
return Optional.ofNullable(jwt);
}
/**
@ -108,5 +111,6 @@ public class Credential extends GeneratedObject {
}
public static final String ID = Credential.class.getCanonicalName();
public static final List<ResourceType> RECOGNIZED_PAYLOAD_TYPES = List.of(SVG, PNG, JSON, JWT);
}

View File

@ -1,24 +1,24 @@
package org.oneedtech.inspect.vc;
import static com.google.common.base.Strings.isNullOrEmpty;
import static java.lang.Boolean.TRUE;
import static org.oneedtech.inspect.core.Inspector.Behavior.RESET_CACHES_ON_RUN;
import static org.oneedtech.inspect.core.report.ReportUtil.onProbeException;
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
import static org.oneedtech.inspect.vc.util.JsonNodeUtil.getEndorsements;
import static org.oneedtech.inspect.vc.Credential.Type.OpenBadgeCredential;
import static org.oneedtech.inspect.vc.EndorsementInspector.ENDORSEMENT_KEY;
import static org.oneedtech.inspect.vc.payload.PayloadParser.fromJwt;
import static org.oneedtech.inspect.vc.util.JsonNodeUtil.asNodeList;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.oneedtech.inspect.core.probe.Outcome;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.probe.RunContext.Key;
import org.oneedtech.inspect.core.probe.json.JsonArrayProbe;
import org.oneedtech.inspect.core.probe.json.JsonPathEvaluator;
import org.oneedtech.inspect.core.probe.json.JsonPredicates.JsonPredicateProbeParams;
import org.oneedtech.inspect.core.probe.json.JsonSchemaProbe;
import org.oneedtech.inspect.core.report.Report;
import org.oneedtech.inspect.core.report.ReportItems;
@ -30,14 +30,16 @@ import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.util.resource.UriResource;
import org.oneedtech.inspect.util.resource.context.ResourceContext;
import org.oneedtech.inspect.util.spec.Specification;
import org.oneedtech.inspect.vc.probe.CredentialTypeProbe;
import org.oneedtech.inspect.vc.probe.CredentialParseProbe;
import org.oneedtech.inspect.vc.probe.ExpirationVerifierProbe;
import org.oneedtech.inspect.vc.probe.InlineJsonSchemaProbe;
import org.oneedtech.inspect.vc.probe.IssuanceVerifierProbe;
import org.oneedtech.inspect.vc.probe.Predicates;
import org.oneedtech.inspect.vc.probe.ProofVerifierProbe;
import org.oneedtech.inspect.vc.probe.RevocationListProbe;
import org.oneedtech.inspect.vc.probe.SignatureVerifierProbe;
import org.oneedtech.inspect.vc.probe.TypePropertyProbe;
import org.oneedtech.inspect.vc.util.CachingDocumentLoader;
import org.oneedtech.inspect.vc.util.JsonNodeUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -60,9 +62,12 @@ public class OB30Inspector extends VCInspector {
@Override
public Report run(Resource resource) {
super.check(resource);
super.check(resource); //TODO because URIs, this should be a fetch and cache
if(getBehavior(RESET_CACHES_ON_RUN) == TRUE) JsonSchemaCache.reset();
if(getBehavior(RESET_CACHES_ON_RUN) == TRUE) {
JsonSchemaCache.reset();
CachingDocumentLoader.reset();
}
ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
@ -77,88 +82,79 @@ public class OB30Inspector extends VCInspector {
List<ReportItems> accumulator = new ArrayList<>();
int probeCount = 0;
try {
//TODO turn into a loop once stable
try {
//detect type (png, svg, json, jwt) and extract json data
probeCount++;
accumulator.add(new CredentialTypeProbe().run(resource, ctx));
accumulator.add(new CredentialParseProbe().run(resource, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//we expect the above to place a generated object in the context
Credential crd = ctx.getGeneratedObject(Credential.ID);
//validate the value of the type property
//type property
probeCount++;
accumulator.add(new JsonArrayProbe(vcType).run(crd.getJson(), ctx));
probeCount++;
accumulator.add(new JsonArrayProbe(obType).run(crd.getJson(), ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//validate against the canonical schema
SchemaKey canonical = crd.getSchemaKey().orElseThrow();
probeCount++;
accumulator.add(new JsonSchemaProbe(canonical).run(crd.getJson(), ctx));
//validate against any inline schemas
probeCount++;
accumulator.add(new InlineJsonSchemaProbe().run(crd, ctx));
//If this credential was originally contained in a JWT we must validate the jwt and external proof.
if(!isNullOrEmpty(crd.getJwt())){
accumulator.add(new TypePropertyProbe(OpenBadgeCredential).run(crd.getJson(), ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//canonical schema and inline schemata
SchemaKey schema = crd.getSchemaKey().orElseThrow();
for(Probe<JsonNode> probe : List.of(new JsonSchemaProbe(schema), new InlineJsonSchemaProbe(schema))) {
probeCount++;
accumulator.add(probe.run(crd.getJson(), ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
//signatures, proofs
probeCount++;
if(crd.getJwt().isPresent()){
//The credential originally contained in a JWT, validate the jwt and external proof.
accumulator.add(new SignatureVerifierProbe().run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
} else {
//The credential not contained in a jwt, must have an internal proof.
accumulator.add(new ProofVerifierProbe().run(crd, ctx));
}
//verify proofs TODO @Miles
//If this credential was not contained in a jwt it must have an internal proof.
if(isNullOrEmpty(crd.getJwt())){
probeCount++;
accumulator.add(new ProofVerifierProbe().run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//check refresh service if we are not already refreshed
probeCount++;
if(resource.getContext().get(REFRESHED) != TRUE) {
Optional<String> newID = checkRefreshService(crd, ctx);
if(newID.isPresent()) {
if(newID.isPresent()) {
//TODO resource.type
return this.run(
new UriResource(new URI(newID.get()))
.setContext(new ResourceContext(REFRESHED, TRUE)));
}
}
//check revocation status
probeCount++;
accumulator.add(new RevocationListProbe().run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//check expiration
probeCount++;
accumulator.add(new ExpirationVerifierProbe().run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//check issuance
probeCount++;
accumulator.add(new IssuanceVerifierProbe().run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
//embedded endorsements
List<JsonNode> endorsements = getEndorsements(crd.getJson(), jsonPath);
if(endorsements.size() > 0) {
EndorsementInspector subInspector = new EndorsementInspector.Builder().build();
for(JsonNode endorsementNode : endorsements) {
probeCount++;
//TODO: @Markus @Miles, need to refactor to detect as this can be an internal or external proof credential.
//This will LIKELY come from two distinct sources in which case we would detect the type by property name.
//Third param to constructor: Compact JWT -> add third param after decoding. Internal Proof, null jwt string.
//Credential endorsement = new Credential(resource, endorsementNode);
//accumulator.add(subInspector.run(resource, Map.of(ENDORSEMENT_KEY, endorsement)));
}
//revocation, expiration and issuance
for(Probe<Credential> probe : List.of(new RevocationListProbe(),
new ExpirationVerifierProbe(), new IssuanceVerifierProbe())) {
probeCount++;
accumulator.add(probe.run(crd, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
//embedded endorsements
EndorsementInspector endorsementInspector = new EndorsementInspector.Builder().build();
List<JsonNode> endorsements = JsonNodeUtil.asNodeList(crd.getJson(), "$..endorsement", jsonPath);
for(JsonNode node : endorsements) {
probeCount++;
Credential endorsement = new Credential(resource, node);
accumulator.add(endorsementInspector.run(resource, Map.of(ENDORSEMENT_KEY, endorsement)));
}
//embedded jwt endorsements
endorsements = JsonNodeUtil.asNodeList(crd.getJson(), "$..endorsementJwt", jsonPath);
for(JsonNode node : endorsements) {
probeCount++;
String jwt = node.asText();
JsonNode vcNode = fromJwt(jwt, ctx);
Credential endorsement = new Credential(resource, vcNode, jwt);
accumulator.add(endorsementInspector.run(resource, Map.of(ENDORSEMENT_KEY, endorsement)));
}
//finally, run any user-added probes
for(Probe<Credential> probe : userProbes) {
probeCount++;
@ -193,13 +189,7 @@ public class OB30Inspector extends VCInspector {
}
private static final String REFRESHED = "is.refreshed.credential";
private static final JsonPredicateProbeParams obType = JsonPredicateProbeParams.of(
"$.type", Predicates.OB30.TypeProperty.value, Predicates.OB30.TypeProperty.msg, Outcome.FATAL);
private static final JsonPredicateProbeParams vcType = JsonPredicateProbeParams.of(
"$.type", Predicates.VC.TypeProperty.value, Predicates.VC.TypeProperty.msg, Outcome.FATAL);
public static class Builder extends VCInspector.Builder<OB30Inspector.Builder> {
@SuppressWarnings("unchecked")
@Override

View File

@ -0,0 +1,32 @@
package org.oneedtech.inspect.vc.payload;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.oneedtech.inspect.util.code.Defensives.checkTrue;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.vc.Credential;
import com.fasterxml.jackson.databind.JsonNode;
/**
* A credential extractor for JSON files.
* @author mgylling
*/
public final class JsonParser extends PayloadParser {
@Override
public boolean supports(ResourceType type) {
return type == ResourceType.JSON;
}
@Override
public Credential parse(Resource resource, RunContext ctx) throws Exception {
checkTrue(resource.getType() == ResourceType.JSON);
String json = resource.asByteSource().asCharSource(UTF_8).read();
JsonNode node = fromString(json, ctx);
return new Credential(resource, node);
}
}

View File

@ -0,0 +1,32 @@
package org.oneedtech.inspect.vc.payload;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.oneedtech.inspect.util.code.Defensives.checkTrue;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.vc.Credential;
import com.fasterxml.jackson.databind.JsonNode;
/**
* A credential extractor for JWT files.
* @author mgylling
*/
public final class JwtParser extends PayloadParser {
@Override
public boolean supports(ResourceType type) {
return type == ResourceType.JWT;
}
@Override
public Credential parse(Resource resource, RunContext ctx) throws Exception {
checkTrue(resource.getType() == ResourceType.JWT);
String jwt = resource.asByteSource().asCharSource(UTF_8).read();
JsonNode node = fromJwt(jwt, ctx);
return new Credential(resource, node, jwt);
}
}

View File

@ -0,0 +1,52 @@
package org.oneedtech.inspect.vc.payload;
import java.util.Base64;
import java.util.List;
import java.util.Base64.Decoder;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.vc.Credential;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Splitter;
/**
* Abstract base for extracting Credential instances from payloads.
* @author mgylling
*/
public abstract class PayloadParser {
public abstract boolean supports(ResourceType type);
public abstract Credential parse(Resource source, RunContext ctx) throws Exception;
protected static JsonNode fromString(String json, RunContext context) throws Exception {
return ((ObjectMapper)context.get(RunContext.Key.JACKSON_OBJECTMAPPER)).readTree(json);
}
/**
* Decode as per https://www.imsglobal.org/spec/ob/v3p0/#jwt-proof
* @return The decoded JSON String
*/
public static JsonNode fromJwt(String jwt, RunContext context) throws Exception {
List<String> parts = Splitter.on('.').splitToList(jwt);
if(parts.size() != 3) throw new IllegalArgumentException("invalid jwt");
final Decoder decoder = Base64.getUrlDecoder();
/*
* For this step we are only deserializing the stored badge out of the payload.
* The entire jwt is stored separately for signature verification later.
*/
String jwtPayload = new String(decoder.decode(parts.get(1)));
//Deserialize and fetch the 'vc' node from the object
JsonNode outerPayload = fromString(jwtPayload, context);
JsonNode vcNode = outerPayload.get("vc");
return vcNode;
}
}

View File

@ -0,0 +1,25 @@
package org.oneedtech.inspect.vc.payload;
import static org.oneedtech.inspect.util.code.Defensives.checkNotNull;
import java.util.List;
import org.oneedtech.inspect.util.resource.Resource;
/**
* A factory to create PayloadParser instances for various resource types.
* @author mgylling
*/
public class PayloadParserFactory {
private static final Iterable<PayloadParser> parsers = List.of(
new PngParser(), new SvgParser(),
new JsonParser(), new JwtParser());
public static PayloadParser of(Resource resource) {
checkNotNull(resource.getType());
for(PayloadParser cex : parsers) {
if(cex.supports(resource.getType())) return cex;
}
throw new IllegalArgumentException();
}
}

View File

@ -0,0 +1,101 @@
package org.oneedtech.inspect.vc.payload;
import static org.oneedtech.inspect.util.code.Defensives.checkTrue;
import java.io.InputStream;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.metadata.IIOMetadata;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.vc.Credential;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import com.fasterxml.jackson.databind.JsonNode;
/**
* A credential extractor for PNG images.
* @author mgylling
*/
public final class PngParser extends PayloadParser {
@Override
public boolean supports(ResourceType type) {
return type == ResourceType.PNG;
}
@Override
public Credential parse(Resource resource, RunContext ctx) throws Exception {
checkTrue(resource.getType() == ResourceType.PNG);
try(InputStream is = resource.asByteSource().openStream()) {
ImageReader imageReader = ImageIO.getImageReadersByFormatName("png").next();
imageReader.setInput(ImageIO.createImageInputStream(is), true);
IIOMetadata metadata = imageReader.getImageMetadata(0);
String credentialString = null;
String jwtString = null;
String formatSearch = null;
JsonNode credential = null;
String[] names = metadata.getMetadataFormatNames();
int length = names.length;
for (int i = 0; i < length; i++) {
//Check all names rather than limiting to PNG format to remain malleable through any library changes. (Could limit to "javax_imageio_png_1.0")
formatSearch = getOpenBadgeCredentialNodeText(metadata.getAsTree(names[i]));
if(formatSearch != null) {
credentialString = formatSearch;
}
}
if(credentialString == null) {
throw new IllegalArgumentException("No credential inside PNG");
}
credentialString = credentialString.trim();
if(credentialString.charAt(0) != '{'){
//This is a jwt. Fetch either the 'vc' out of the payload and save the string for signature verification.
jwtString = credentialString;
credential = fromJwt(credentialString, ctx);
}
else {
credential = fromString(credentialString, ctx);
}
return new Credential(resource, credential, jwtString);
}
}
private String getOpenBadgeCredentialNodeText(Node node){
NamedNodeMap attributes = node.getAttributes();
//If this node is labeled with the attribute keyword: 'openbadgecredential' it is the right one.
Node keyword = attributes.getNamedItem("keyword");
if(keyword != null && keyword.getNodeValue().equals("openbadgecredential")){
Node textAttribute = attributes.getNamedItem("text");
if(textAttribute != null) {
return textAttribute.getNodeValue();
}
}
//iterate over all children depth first and search for the credential node.
Node child = node.getFirstChild();
while (child != null) {
String nodeValue = getOpenBadgeCredentialNodeText(child);
if(nodeValue != null) {
return nodeValue;
}
child = child.getNextSibling();
}
//Return null if we haven't found anything at this recursive depth.
return null;
}
}

View File

@ -0,0 +1,79 @@
package org.oneedtech.inspect.vc.payload;
import static org.oneedtech.inspect.util.code.Defensives.checkTrue;
import java.io.InputStream;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.Characters;
import javax.xml.stream.events.XMLEvent;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.util.code.Defensives;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.util.xml.XMLInputFactoryCache;
import org.oneedtech.inspect.vc.Credential;
import com.fasterxml.jackson.databind.JsonNode;
/**
* A credential extractor for SVG documents.
* @author mgylling
*/
public final class SvgParser extends PayloadParser {
@Override
public boolean supports(ResourceType type) {
return type == ResourceType.SVG;
}
@Override
public Credential parse(Resource resource, RunContext ctx) throws Exception {
checkTrue(resource.getType() == ResourceType.SVG);
try(InputStream is = resource.asByteSource().openStream()) {
XMLEventReader reader = XMLInputFactoryCache.getInstance().createXMLEventReader(is);
while(reader.hasNext()) {
XMLEvent ev = reader.nextEvent();
if(isEndElem(ev, OB_CRED_ELEM)) break;
if(isStartElem(ev, OB_CRED_ELEM)) {
Attribute verifyAttr = ev.asStartElement().getAttributeByName(OB_CRED_VERIFY_ATTR);
if(verifyAttr != null) {
String jwt = verifyAttr.getValue();
JsonNode node = fromJwt(jwt, ctx);
return new Credential(resource, node, jwt);
} else {
while(reader.hasNext()) {
ev = reader.nextEvent();
if(isEndElem(ev, OB_CRED_ELEM)) break;
if(ev.getEventType() == XMLEvent.CHARACTERS) {
Characters chars = ev.asCharacters();
if(!chars.isWhiteSpace()) {
JsonNode node = fromString(chars.getData(), ctx);
return new Credential(resource, node);
}
}
}
}
}
} //while(reader.hasNext()) {
}
throw new IllegalArgumentException("No credential inside SVG");
}
private boolean isEndElem(XMLEvent ev, QName name) {
return ev.isEndElement() && ev.asEndElement().getName().equals(name);
}
private boolean isStartElem(XMLEvent ev, QName name) {
return ev.isStartElement() && ev.asStartElement().getName().equals(name);
}
private static final QName OB_CRED_ELEM = new QName("https://purl.imsglobal.org/ob/v3p0", "credential");
private static final QName OB_CRED_VERIFY_ATTR = new QName("verify");
}

View File

@ -0,0 +1,54 @@
package org.oneedtech.inspect.vc.probe;
import java.util.Optional;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.util.resource.detect.TypeDetector;
import org.oneedtech.inspect.vc.Credential;
import org.oneedtech.inspect.vc.payload.PayloadParserFactory;
/**
* A probe that verifies that the incoming credential resource is of a recognized payload type
* and if so extracts and stores the VC json data (a 'Credential' instance)
* in the RunContext.
* @author mgylling
*/
public class CredentialParseProbe extends Probe<Resource> {
@Override
public ReportItems run(Resource resource, RunContext context) throws Exception {
try {
//TODO if .detect reads from a URIResource twice. Cache the resource on first call.
Optional<ResourceType> type = Optional.ofNullable(resource.getType());
if(type.isEmpty() || type.get() == ResourceType.UNKNOWN) {
type = TypeDetector.detect(resource, true);
if(type.isEmpty()) {
//TODO if URI fetch, TypeDetector likely to fail
System.err.println("typedetector fail: extend behavior here");
return fatal("Could not detect credential payload type", context);
} else {
resource.setType(type.get());
}
}
if(!Credential.RECOGNIZED_PAYLOAD_TYPES.contains(type.get())) {
return fatal("Payload type not supported: " + type.get().getName(), context);
}
Credential crd = PayloadParserFactory.of(resource).parse(resource, context);
context.addGeneratedObject(crd);
return success(this, context);
} catch (Exception e) {
return fatal("Error while parsing credential: " + e.getMessage(), context);
}
}
}

View File

@ -1,234 +0,0 @@
package org.oneedtech.inspect.vc.probe;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.InputStream;
import java.util.Base64;
import java.util.Base64.Decoder;
import java.util.List;
import java.util.Optional;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.metadata.IIOMetadata;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.Characters;
import javax.xml.stream.events.XMLEvent;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.util.resource.detect.TypeDetector;
import org.oneedtech.inspect.util.xml.XMLInputFactoryCache;
import org.oneedtech.inspect.vc.Credential;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Splitter;
/**
* A probe that verifies that the incoming credential resource is of a recognized type,
* and if so extracts and stores the VC json data (a 'Credential' instance) in the RunContext.
* @author mgylling
*/
public class CredentialTypeProbe extends Probe<Resource> {
@Override
public ReportItems run(Resource resource, RunContext context) throws Exception {
Credential crd = null;
try {
//TODO: this reads from a URIResource twice. Cache the resource on first call.
Optional<ResourceType> type = TypeDetector.detect(resource, true);
if(type.isPresent()) {
resource.setType(type.get());
if(type.get() == ResourceType.PNG) {
crd = fromPNG(resource, context);
} else if(type.get() == ResourceType.SVG) {
crd = fromSVG(resource, context);
} else if(type.get() == ResourceType.JSON) {
crd = fromJson(resource, context);
} else if(type.get() == ResourceType.JWT) {
crd = fromJWT(resource, context);
}
}
if(crd != null) {
context.addGeneratedObject(crd);
return success(this, context);
} else {
return fatal("Could not detect credential type", context);
}
} catch (Exception e) {
return fatal("Error while detecting credential type: " + e.getMessage(), context);
}
}
/**
* Extract the JSON data from a baked PNG credential.
* @param context
* @throws Exception
*/
private Credential fromPNG(Resource resource, RunContext context) throws Exception {
try(InputStream is = resource.asByteSource().openStream()) {
ImageReader imageReader = ImageIO.getImageReadersByFormatName("png").next();
imageReader.setInput(ImageIO.createImageInputStream(is), true);
IIOMetadata metadata = imageReader.getImageMetadata(0);
String credentialString = null;
String jwtString = null;
String formatSearch = null;
JsonNode credential = null;
String[] names = metadata.getMetadataFormatNames();
int length = names.length;
for (int i = 0; i < length; i++)
{
//Check all names rather than limiting to PNG format to remain malleable through any library changes. (Could limit to "javax_imageio_png_1.0")
formatSearch = getOpenBadgeCredentialNodeText(metadata.getAsTree(names[i]));
if(formatSearch != null) { credentialString = formatSearch; }
}
if(credentialString == null) { throw new IllegalArgumentException("No credential inside PNG"); }
credentialString = credentialString.trim();
if(credentialString.charAt(0) != '{'){
//This is a jwt. Fetch either the 'vc' out of the payload and save the string for signature verification.
jwtString = credentialString;
credential = decodeJWT(credentialString,context);
}
else {
credential = buildNodeFromString(credentialString, context);
}
return new Credential(resource, credential, jwtString);
}
}
/**
* Extract the JSON data from a baked SVG credential.
* @param context
* @throws Exception
*/
private Credential fromSVG(Resource resource, RunContext context) throws Exception {
String json = null;
String jwtString = null;
JsonNode credential = null;;
try(InputStream is = resource.asByteSource().openStream()) {
XMLEventReader reader = XMLInputFactoryCache.getInstance().createXMLEventReader(is);
while(reader.hasNext()) {
XMLEvent ev = reader.nextEvent();
if(ev.isStartElement() && ev.asStartElement().getName().equals(OB_CRED_ELEM)) {
Attribute verifyAttr = ev.asStartElement().getAttributeByName(OB_CRED_VERIFY_ATTR);
if(verifyAttr != null) {
jwtString = verifyAttr.getValue();
credential = decodeJWT(verifyAttr.getValue(), context);
break;
} else {
while(reader.hasNext()) {
ev = reader.nextEvent();
if(ev.isEndElement() && ev.asEndElement().getName().equals(OB_CRED_ELEM)) {
break;
}
if(ev.getEventType() == XMLEvent.CHARACTERS) {
Characters chars = ev.asCharacters();
if(!chars.isWhiteSpace()) {
json = chars.getData();
credential = buildNodeFromString(json, context);
break;
}
}
}
}
}
if(credential!=null) break;
}
}
if(credential == null) throw new IllegalArgumentException("No credential inside SVG");
return new Credential(resource, credential, jwtString);
}
/**
* Create a Credential object from a raw JSON resource.
* @param context
*/
private Credential fromJson(Resource resource, RunContext context) throws Exception {
return new Credential(resource, buildNodeFromString(resource.asByteSource().asCharSource(UTF_8).read(), context), null);
}
/**
* Create a Credential object from a JWT resource.
* @param context
*/
private Credential fromJWT(Resource resource, RunContext context) throws Exception {
return new Credential(
resource,
decodeJWT(
resource.asByteSource().asCharSource(UTF_8).read(),
context
)
, resource.asByteSource().asCharSource(UTF_8).read()
);
}
/**
* Create a JsonNode object from a String.
*/
private JsonNode buildNodeFromString(String json, RunContext context) throws Exception {
return ((ObjectMapper)context.get(RunContext.Key.JACKSON_OBJECTMAPPER)).readTree(json);
}
/**
* Decode as per https://www.imsglobal.org/spec/ob/v3p0/#jwt-proof
* @return The decoded JSON String
*/
private JsonNode decodeJWT(String jwt, RunContext context) throws Exception {
List<String> parts = Splitter.on('.').splitToList(jwt);
if(parts.size() != 3) throw new IllegalArgumentException("invalid jwt");
final Decoder decoder = Base64.getUrlDecoder();
//For this step we are only deserializing the stored badge out of the payload. The entire jwt is stored separately for
//signature verification later.
String jwtPayload = new String(decoder.decode(parts.get(1)));
//Deserialize and fetch the 'vc' node from the object
JsonNode outerPayload = buildNodeFromString(jwtPayload, context);
JsonNode vcNode = outerPayload.get("vc");
return vcNode;
}
private String getOpenBadgeCredentialNodeText(Node node){
NamedNodeMap attributes = node.getAttributes();
//If this node is labeled with the attribute keyword: 'openbadgecredential' it is the right one.
if(attributes.getNamedItem("keyword") != null && attributes.getNamedItem("keyword").getNodeValue().equals("openbadgecredential")){
Node textAttribute = attributes.getNamedItem("text");
if(textAttribute != null) { return textAttribute.getNodeValue(); }
}
//iterate over all children depth first and search for the credential node.
Node child = node.getFirstChild();
while (child != null)
{
String nodeValue = getOpenBadgeCredentialNodeText(child);
if(nodeValue != null) { return nodeValue; }
child = child.getNextSibling();
}
//Return null if we haven't found anything at this recursive depth.
return null;
}
private static final QName OB_CRED_ELEM = new QName("https://purl.imsglobal.org/ob/v3p0", "credential");
private static final QName OB_CRED_VERIFY_ATTR = new QName("verify");
}

View File

@ -20,16 +20,21 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
* Detect inline schemas in a credential and run them.
* @author mgylling
*/
public class InlineJsonSchemaProbe extends Probe<Credential> {
public class InlineJsonSchemaProbe extends Probe<JsonNode> {
private static final Set<String> types = Set.of("1EdTechJsonSchemaValidator2019");
private final boolean skipCanonical = true;
private SchemaKey skip;
public InlineJsonSchemaProbe() {
super(ID);
}
public InlineJsonSchemaProbe(SchemaKey skip) {
super(ID);
this.skip = skip;
}
@Override
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
public ReportItems run(JsonNode root, RunContext ctx) throws Exception {
List<ReportItems> accumulator = new ArrayList<>();
Set<String> ioErrors = new HashSet<>();
@ -37,7 +42,7 @@ public class InlineJsonSchemaProbe extends Probe<Credential> {
// ArrayNode nodes = jsonPath.eval("$..*[?(@.credentialSchema)]", crd.getJson());
// note - we dont get deep nested ones in e.g. EndorsementCredential
JsonNode credentialSchemaNode = crd.getJson().get("credentialSchema");
JsonNode credentialSchemaNode = root.get("credentialSchema");
if(credentialSchemaNode == null) return success(ctx);
ArrayNode schemas = (ArrayNode) credentialSchemaNode; //TODO guard this cast
@ -49,9 +54,9 @@ public class InlineJsonSchemaProbe extends Probe<Credential> {
if(idNode == null) continue;
String id = idNode.asText().strip();
if(ioErrors.contains(id)) continue;
if(skipCanonical && equals(crd.getSchemaKey(), id)) continue;
if(equals(skip, id)) continue;
try {
accumulator.add(new JsonSchemaProbe(id).run(crd.getJson(), ctx));
accumulator.add(new JsonSchemaProbe(id).run(root, ctx));
} catch (Exception e) {
if(!ioErrors.contains(id)) {
ioErrors.add(id);
@ -63,8 +68,9 @@ public class InlineJsonSchemaProbe extends Probe<Credential> {
return new ReportItems(accumulator);
}
private boolean equals(Optional<SchemaKey> key, String id) {
return key.isPresent() && key.get().getCanonicalURI().equals(id);
private boolean equals(SchemaKey key, String id) {
if(key == null) return false;
return key.getCanonicalURI().equals(id);
}
public static final String ID = InlineJsonSchemaProbe.class.getSimpleName();

View File

@ -34,7 +34,7 @@ public class IssuanceVerifierProbe extends Probe<Credential> {
try {
issuanceDate = ZonedDateTime.parse(node.textValue());
if (issuanceDate.isAfter(now)) {
return fatal("The credential is not yet valid (issuance date is " + node.asText() + ").", ctx);
return fatal("The credential is not yet issued (issuance date is " + node.asText() + ").", ctx);
}
} catch (Exception e) {
return exception("Error while checking issuanceDate: " + e.getMessage(), ctx.getResource());

View File

@ -1,51 +0,0 @@
package org.oneedtech.inspect.vc.probe;
import static org.oneedtech.inspect.vc.Credential.Type.*;
import static org.oneedtech.inspect.vc.util.JsonNodeUtil.asStringList;
import java.util.List;
import java.util.function.Predicate;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.base.Joiner;
//TODO refactor
public class Predicates {
public static class OB30 {
public static class TypeProperty {
public static final Predicate<JsonNode> value = new Predicate<>() {
@Override
public boolean test(JsonNode node) {
List<String> values = asStringList(node);
for(String exp : exp) {
if(values.contains(exp)) return true;
}
return false;
}
};
private static final List<String> exp = List.of(OpenBadgeCredential.name(), AchievementCredential.name(), VerifiablePresentation.name());
public static final String msg = "The type property does not contain one of " + Joiner.on(", ").join(exp);
}
}
public static class VC {
public static class TypeProperty {
public static final Predicate<JsonNode> value = new Predicate<>() {
@Override
public boolean test(JsonNode node) {
List<String> values = asStringList(node);
if(values.contains(exp)) return true;
return false;
}
};
private static final String exp = VerifiableCredential.name();
public static final String msg = "The type property does not contain " + exp;
}
}
}

View File

@ -1,236 +1,66 @@
package org.oneedtech.inspect.vc.probe;
import static org.oneedtech.inspect.core.probe.RunContext.Key.JACKSON_OBJECTMAPPER;
import java.io.ByteArrayOutputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Security;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Base64.Encoder;
import java.util.Map.Entry;
import org.bouncycastle.asn1.edec.EdECObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.crypto.Signer;
import org.bouncycastle.crypto.signers.Ed25519Signer;
import org.bouncycastle.crypto.signers.RSADigestSigner;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Hex;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.vc.Credential;
import org.oneedtech.inspect.vc.util.CachingDocumentLoader;
import com.apicatalog.jsonld.JsonLd;
import com.apicatalog.jsonld.StringUtils;
import com.apicatalog.jsonld.document.JsonDocument;
import com.apicatalog.jsonld.http.media.MediaType;
import com.apicatalog.rdf.Rdf;
import com.apicatalog.rdf.RdfDataset;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.bitcoin.core.Base58;
import com.google.common.io.BaseEncoding;
import com.apicatalog.ld.DocumentError;
import com.apicatalog.ld.signature.VerificationError;
import com.apicatalog.vc.Vc;
import com.apicatalog.vc.processor.StatusVerifier;
import io.setl.rdf.normalization.RdfNormalize;
import jakarta.json.JsonObject;
/**
* A Probe that verifies credential proofs
* @author mlyon
* A Probe that verifies a credential's proof.
* @author mgylling
*/
public class ProofVerifierProbe extends Probe<Credential> {
/*
* Note: using com.apicatalog Iron, we get a generic VC verifier that
* will test other stuff than the Proof. So sometimes it may be that
* Iron internally retests something that we're already testing out in the
* Inspector class (e.g. expiration). But use this for now -- and remember
* that this probe is only run if the given credential has internal proof
* (aka is not a jwt).
*/
public ProofVerifierProbe() {
super(ID);
}
@Override
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
JsonDocument jsonDoc = JsonDocument.of(new StringReader(crd.getJson().toString()));
JsonObject json = jsonDoc.getJsonContent().get().asJsonObject();
try {
String document = fetchConanicalDocument(crd, C14n.URDNA2015, MediaType.N_QUADS, ctx);
String proof = fetchConanicalProof(crd, C14n.URDNA2015, MediaType.N_QUADS, ctx);
//System.out.println(canonical);
byte[] docHash = getBytes(document);
byte[] proofHash = getBytes(proof);
// concatenate hash of c14n proof options and hash of c14n document
byte[] combined = mergeArrays(proofHash, docHash);
boolean test = testSigner(combined);
//TODO if proofs fail, report OutCome.Fatal
//return fatal("msg", ctx);
} catch (Exception e) {
return exception(e.getMessage(), crd.getResource());
}
Vc.verify(json)
.loader(new CachingDocumentLoader())
.useBundledContexts(false) //we control the cache in the loader
//.statusVerifier(new NoopStatusVerifier())
//.domain(...)
//.didResolver(...)
.isValid();
} catch (DocumentError e) {
return error(e.getType() + " " + e.getSubject(), ctx);
} catch (VerificationError e) {
return error(e.getCode().name() + " " + e.getMessage(), ctx);
}
return success(ctx);
}
private String fetchConanicalDocument(Credential crd, C14n algo, MediaType mediaType, RunContext ctx) throws Exception {
}
//clone the incoming credential object so we can modify it freely
ObjectMapper mapper = (ObjectMapper)ctx.get(JACKSON_OBJECTMAPPER);
JsonNode copy = mapper.readTree(crd.getJson().toString());
//remove proof
((ObjectNode)copy).remove("proof");
//create JSON-P Json-LD instance
JsonDocument jsonLdDoc = JsonDocument.of(new StringReader(copy.toString()));
//create rdf and normalize
RdfDataset dataSet = JsonLd.toRdf(jsonLdDoc).loader(new CachingDocumentLoader()).ordered(true).get();
RdfDataset normalized = RdfNormalize.normalize(dataSet);
//serialize to string
ByteArrayOutputStream os = new ByteArrayOutputStream();
Rdf.createWriter(mediaType, os).write(normalized);
String result = StringUtils.stripTrailing(os.toString());
return result;
}
private String fetchConanicalProof(Credential crd, C14n algo, MediaType mediaType, RunContext ctx) throws Exception {
//clone the incoming credential object so we can modify it freely
ObjectMapper mapper = (ObjectMapper)ctx.get(JACKSON_OBJECTMAPPER);
JsonNode copy = mapper.readTree(crd.getJson().toString());
//Get the context node to stitch back in.
JsonNode context = copy.get("@context");
//Pull out and use proof node only
JsonNode proof = copy.get("proof");
//TODO: Make this better at discarding all, but the linked data proof method.
if(proof.isArray()){
proof = proof.get(0);
}
//remove these if they exist
((ObjectNode)proof).remove("jwt");
((ObjectNode)proof).remove("signatureValue");
((ObjectNode)proof).remove("proofValue");
JsonNode newNode = mapper.createObjectNode();
((ObjectNode) newNode).set("@context", context);
//Try to structure this 'to the letter' per a slack with Markus
//((ObjectNode) newNode).set("proof", proof);
//So that we don't remove nodes while iterating over it save all nodes
Iterator<Entry<String,JsonNode>> iter = proof.fields();
while (iter.hasNext()) {
Entry<String,JsonNode> next = iter.next();
((ObjectNode) newNode).set(next.getKey(), next.getValue());
}
//create JSON-P Json-LD instance
JsonDocument jsonLdDoc = JsonDocument.of(new StringReader(newNode.toString()));
//create rdf and normalize
//RdfDataset dataSet = JsonLd.toRdf(jsonLdDoc).ordered(true).get();
RdfDataset dataSet = JsonLd.toRdf(jsonLdDoc).loader(new CachingDocumentLoader()).ordered(true).get();
RdfDataset normalized = RdfNormalize.normalize(dataSet);
//serialize to string
ByteArrayOutputStream os = new ByteArrayOutputStream();
Rdf.createWriter(mediaType, os).write(normalized);
String result = StringUtils.stripTrailing(os.toString());
return result;
}
private boolean testSigner(byte[] concatBytes) throws Exception {
final var provider = new BouncyCastleProvider();
Security.addProvider(provider);
//Base 58 decode minus the z
var publicKeyBytes = Base58.decode("6MkkUD3J14nkYzn46QeuaVSnp7dF85QJKwKvJvfsjx79aXj");
//The slice out the first two array elements (???)
byte[] slicedArray = Arrays.copyOfRange(publicKeyBytes, 2, 34);
final var pki = new SubjectPublicKeyInfo(new AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519), slicedArray);
final var pkSpec = new X509EncodedKeySpec(pki.getEncoded());
final var kf = KeyFactory.getInstance("ed25519", provider);
final var publicKey = kf.generatePublic(pkSpec);
final var signedData = Signature.getInstance("ed25519", provider);
signedData.initVerify(publicKey);
signedData.update(concatBytes);
var signatureBytes = Base58.decode("3MUt2ZuU8Byqivxh6GphEM65AFYyNaGYibm97xLTafM7uGufZQLKvJR8itZwxKskvtFM3CUty46v26DZidMNoQnM");
return signedData.verify(signatureBytes);
}
//An alternate path with bouncy castle
/*
private boolean testSigner3(String message, byte[] concateBytes) throws Exception {
// Test case defined in https://www.rfc-editor.org/rfc/rfc8037
var msg = "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc".getBytes(StandardCharsets.UTF_8);
var expectedSig = "hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg";
var privateKeyBytes = Base64.getUrlDecoder().decode("nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A");
var publicKeyBytes = Base64.getUrlDecoder().decode("11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo");
var privateKey = new Ed25519PrivateKeyParameters(privateKeyBytes, 0);
var publicKey = new Ed25519PublicKeyParameters(publicKeyBytes, 0);
// Generate new signature
Signer signer = new Ed25519Signer();
signer.init(true, privateKey);
signer.update(msg, 0, msg.length);
byte[] signature = signer.generateSignature();
var actualSignature = Base64.getUrlEncoder().encodeToString(signature).replace("=", "");
LOG.info("Expected signature: {}", expectedSig);
LOG.info("Actual signature : {}", actualSignature);
assertEquals(expectedSig, actualSignature);
return true;
}
*/
private byte[] getBytes(String value) throws Exception{
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(
value.getBytes(StandardCharsets.UTF_8)
);
}
private static byte[] mergeArrays(final byte[] array1, byte[] array2) {
byte[] joinedArray = Arrays.copyOf(array1, array1.length + array2.length);
System.arraycopy(array2, 0, joinedArray, array1.length, array2.length);
return joinedArray;
private static final class NoopStatusVerifier implements StatusVerifier {
@Override
public void verify(Status status) throws DocumentError, VerifyError {
// noop
}
}
private enum C14n {
URDNA2015
}
public static final String ID = ProofVerifierProbe.class.getSimpleName();
public static final String ID = ProofVerifierProbe.class.getSimpleName();
}

View File

@ -1,22 +1,22 @@
package org.oneedtech.inspect.vc.probe;
import static com.google.common.base.Strings.isNullOrEmpty;
import static org.oneedtech.inspect.util.code.Defensives.checkTrue;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.List;
import java.util.Base64;
import java.util.Base64.Decoder;
import java.util.List;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.util.code.Defensives;
import org.oneedtech.inspect.vc.Credential;
import org.springframework.util.Base64Utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
@ -43,25 +43,27 @@ public class SignatureVerifierProbe extends Probe<Credential> {
@Override
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
try {
verifySignature(crd);
verifySignature(crd, ctx);
} catch (Exception e) {
return fatal("Error verifying jwt signature: " + e.getMessage(), ctx);
}
}
return success(ctx);
}
private void verifySignature(Credential crd) throws Exception {
DecodedJWT decodedJwt = null;
String jwt = crd.getJwt();
if(isNullOrEmpty(jwt)) throw new IllegalArgumentException("invalid jwt");
private void verifySignature(Credential crd, RunContext ctx) throws Exception {
checkTrue(crd.getJwt().isPresent(), "no jwt supplied");
checkTrue(crd.getJwt().get().length() > 0, "no jwt supplied");
DecodedJWT decodedJwt = null;
String jwt = crd.getJwt().get();
List<String> parts = Splitter.on('.').splitToList(jwt);
if(parts.size() != 3) throw new IllegalArgumentException("invalid jwt");
final Decoder decoder = Base64.getUrlDecoder();
String joseHeader = new String(decoder.decode(parts.get(0)));
ObjectMapper mapper = new ObjectMapper();
ObjectMapper mapper = ((ObjectMapper)ctx.get(RunContext.Key.JACKSON_OBJECTMAPPER));
JsonNode headerObj = mapper.readTree(joseHeader);
//MUST be "RS256"
@ -86,9 +88,13 @@ public class SignatureVerifierProbe extends Probe<Credential> {
String modulusString = jwk.get("n").textValue();
String exponentString = jwk.get("e").textValue();
BigInteger modulus = new BigInteger(1, org.springframework.util.Base64Utils.decodeFromUrlSafeString(modulusString));
BigInteger exponent = new BigInteger(1, org.springframework.util.Base64Utils.decodeFromUrlSafeString(exponentString));
// BigInteger modulus = new BigInteger(1, org.springframework.util.Base64Utils.decodeFromUrlSafeString(modulusString));
// BigInteger exponent = new BigInteger(1, org.springframework.util.Base64Utils.decodeFromUrlSafeString(exponentString));
// mgy: use java util decoder instead of spring?
BigInteger modulus = new BigInteger(1, decoder.decode(modulusString));
BigInteger exponent = new BigInteger(1, decoder.decode(exponentString));
PublicKey pub = KeyFactory.getInstance("RSA").generatePublic(new RSAPublicKeySpec(modulus, exponent));
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey)pub, null);

View File

@ -0,0 +1,55 @@
package org.oneedtech.inspect.vc.probe;
import static org.oneedtech.inspect.util.code.Defensives.checkNotNull;
import java.util.List;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.vc.Credential;
import org.oneedtech.inspect.vc.util.JsonNodeUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
/**
* A Probe that verifies a credential's type property.
* @author mgylling
*/
public class TypePropertyProbe extends Probe<JsonNode> {
private final Credential.Type expected;
public TypePropertyProbe(Credential.Type expected) {
super(ID);
this.expected = checkNotNull(expected);
}
@Override
public ReportItems run(JsonNode root, RunContext ctx) throws Exception {
ArrayNode typeNode = (ArrayNode)root.get("type");
if(typeNode == null) return fatal("No type property", ctx);
List<String> values = JsonNodeUtil.asStringList(typeNode);
if(!values.contains("VerifiableCredential")) {
return fatal("The type property does not contain the entry 'VerifiableCredential'", ctx);
}
if(expected == Credential.Type.OpenBadgeCredential) {
if(!values.contains("OpenBadgeCredential") && !values.contains("AchievementCredential")) {
return fatal(
"The type property does not contain one of 'OpenBadgeCredential' or 'AchievementCredential'",
ctx);
}
} else {
//TODO implement
throw new IllegalStateException();
}
return success(ctx);
}
public static final String ID = TypePropertyProbe.class.getSimpleName();
}

View File

@ -22,7 +22,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.io.Resources;
/**
* A DocumentLoader with a threadsafe static cache.
* A com.apicatalog DocumentLoader with a threadsafe static cache.
* @author mgylling
*/
public class CachingDocumentLoader implements DocumentLoader {
@ -33,7 +33,7 @@ public class CachingDocumentLoader implements DocumentLoader {
try {
return documentCache.get(tpl);
} catch (Exception e) {
logger.error("contextCache not able to load {}", url);
logger.error("documentCache not able to load {}", url);
throw new JsonLdError(JsonLdErrorCode.INVALID_REMOTE_CONTEXT, e.getMessage());
}
}

View File

@ -11,27 +11,27 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
/**
* Node access utilities.
* Json node utilities.
* @author mgylling
*/
public class JsonNodeUtil {
/**
* Get all embedded endorsement objects as a flat list.
* @return a List that is never null but may be empty.
*/
public static List<JsonNode> getEndorsements(JsonNode root, JsonPathEvaluator jsonPath) {
public static List<JsonNode> asNodeList(JsonNode root, String jsonPath, JsonPathEvaluator evaluator) {
List<JsonNode> list = new ArrayList<>();
ArrayNode endorsements = jsonPath.eval("$..endorsement", root);
for(JsonNode endorsement : endorsements) {
ArrayNode values = (ArrayNode) endorsement;
for(JsonNode value : values) {
list.add(value);
ArrayNode array = evaluator.eval(jsonPath, root);
for(JsonNode node : array) {
if(!(node instanceof ArrayNode)) {
list.add(node);
} else {
ArrayNode values = (ArrayNode) node;
for(JsonNode value : values) {
list.add(value);
}
}
}
return list;
}
public static List<String> asStringList(JsonNode node) {
if(!(node instanceof ArrayNode)) {
return List.of(node.asText());

View File

@ -1,6 +1,6 @@
package org.oneedtech.inspect.vc;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.*;
import static org.oneedtech.inspect.test.Assertions.*;
import org.junit.jupiter.api.BeforeAll;
@ -9,12 +9,17 @@ import org.junit.jupiter.api.Test;
import org.oneedtech.inspect.core.Inspector.Behavior;
import org.oneedtech.inspect.core.report.Report;
import org.oneedtech.inspect.test.PrintHelper;
import org.oneedtech.inspect.vc.probe.ExpirationVerifierProbe;
import org.oneedtech.inspect.vc.probe.InlineJsonSchemaProbe;
import org.oneedtech.inspect.vc.probe.IssuanceVerifierProbe;
import org.oneedtech.inspect.vc.probe.ProofVerifierProbe;
import org.oneedtech.inspect.vc.probe.TypePropertyProbe;
import com.google.common.collect.Iterables;
public class OB30Tests {
private static OB30Inspector validator;
private static boolean verbose = true;
private static boolean verbose = false;
@BeforeAll
static void setup() {
@ -32,6 +37,7 @@ public class OB30Tests {
});
}
@Disabled //TODO @Miles -- this needs update?
@Test
void testSimplePNGPlainValid() {
assertDoesNotThrow(()->{
@ -50,6 +56,7 @@ public class OB30Tests {
});
}
@Disabled //TODO @Miles -- this needs update?
@Test
void testSimpleJsonSVGPlainValid() {
assertDoesNotThrow(()->{
@ -58,8 +65,7 @@ public class OB30Tests {
assertValid(report);
});
}
@Disabled
@Test
void testSimpleJsonSVGJWTValid() {
assertDoesNotThrow(()->{
@ -71,20 +77,60 @@ public class OB30Tests {
@Test
void testSimpleJsonInvalidUnknownType() {
//add a dumb value to .type and remove the ob type
assertDoesNotThrow(()->{
Report report = validator.run(Samples.OB30.JSON.SIMPLE_JSON_UNKNOWN_TYPE.asFileResource());
if(verbose) PrintHelper.print(report, true);
assertInvalid(report);
assertFatalCount(report, 1);
assertHasProbeID(report, TypePropertyProbe.ID, true);
});
}
@Test
void testCompleteJsonInvalidInlineSchemaRef() throws Exception {
void testSimpleJsonInvalidProof() {
//add some garbage chars to proofValue
assertDoesNotThrow(()->{
Report report = validator.run(Samples.OB30.JSON.COMPLETE_JSON.asFileResource());
Report report = validator.run(Samples.OB30.JSON.SIMPLE_JSON_PROOF_ERROR.asFileResource());
if(verbose) PrintHelper.print(report, true);
assertInvalid(report);
assertErrorCount(report, 1);
assertHasProbeID(report, ProofVerifierProbe.ID, true);
});
}
@Test
void testSimpleJsonExpired() {
//"expirationDate": "2020-01-20T00:00:00Z",
assertDoesNotThrow(()->{
Report report = validator.run(Samples.OB30.JSON.SIMPLE_JSON_EXPIRED.asFileResource());
if(verbose) PrintHelper.print(report, true);
assertInvalid(report);
assertHasProbeID(report, ExpirationVerifierProbe.ID, true);
});
}
@Test
void testSimpleJsonNotIssued() {
//"issuanceDate": "2040-01-01T00:00:00Z",
//this breaks the proof too
assertDoesNotThrow(()->{
Report report = validator.run(Samples.OB30.JSON.SIMPLE_JSON_ISSUED.asFileResource());
if(verbose) PrintHelper.print(report, true);
assertInvalid(report);
assertHasProbeID(report, IssuanceVerifierProbe.ID, true);
});
}
@Test
void testCompleteJsonInvalidInlineSchemaRef() throws Exception {
//404 inline schema ref, and 404 refresh uri
assertDoesNotThrow(()->{
Report report = validator.run(Samples.OB30.JSON.COMPLETE_JSON.asFileResource());
if(verbose) PrintHelper.print(report, true);
assertFalse(report.asBoolean());
assertTrue(Iterables.size(report.getErrors()) > 0);
assertTrue(Iterables.size(report.getExceptions()) > 0);
assertHasProbeID(report, InlineJsonSchemaProbe.ID, true);
});
}

View File

@ -13,6 +13,9 @@ public class Samples {
public final static Sample COMPLETE_JSON = new Sample("ob30/complete.json", false);
public final static Sample SIMPLE_JSON = new Sample("ob30/simple.json", true);
public final static Sample SIMPLE_JSON_UNKNOWN_TYPE = new Sample("ob30/simple-unknown-type.json", false);
public final static Sample SIMPLE_JSON_PROOF_ERROR = new Sample("ob30/simple-proof-error.json", false);
public final static Sample SIMPLE_JSON_EXPIRED = new Sample("ob30/simple-expired.json", false);
public final static Sample SIMPLE_JSON_ISSUED = new Sample("ob30/simple-issued.json", false);
}
public static final class PNG {
public final static Sample SIMPLE_JWT_PNG = new Sample("ob30/simple-jwt.png", true);

View File

@ -0,0 +1,119 @@
package org.oneedtech.inspect.vc.credential;
import static org.junit.jupiter.api.Assertions.*;
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.probe.RunContext.Key;
import org.oneedtech.inspect.core.probe.json.JsonPathEvaluator;
import org.oneedtech.inspect.util.json.ObjectMapperCache;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.vc.Credential;
import org.oneedtech.inspect.vc.OB30Inspector;
import org.oneedtech.inspect.vc.Samples;
import org.oneedtech.inspect.vc.payload.PayloadParser;
import org.oneedtech.inspect.vc.payload.PayloadParserFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
public class PayloadParserTests {
@Test
void testSvgStringExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.SVG.SIMPLE_JSON_SVG.asFileResource(ResourceType.SVG);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
@Test
void testSvgJwtExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.SVG.SIMPLE_JWT_SVG.asFileResource(ResourceType.SVG);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
@Test
void testPngStringExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.PNG.SIMPLE_JSON_PNG.asFileResource(ResourceType.PNG);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
@Test
void testPngJwtExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.PNG.SIMPLE_JWT_PNG.asFileResource(ResourceType.PNG);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
@Test
void testJwtExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.JWT.SIMPLE_JWT.asFileResource(ResourceType.JWT);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
@Test
void testJsonExtract() {
assertDoesNotThrow(()->{
Resource res = Samples.OB30.JSON.SIMPLE_JSON.asFileResource(ResourceType.JSON);
PayloadParser ext = PayloadParserFactory.of(res);
assertNotNull(ext);
Credential crd = ext.parse(res, mockOB30Context(res));
//System.out.println(crd.getJson().toPrettyString());
assertNotNull(crd);
assertNotNull(crd.getJson());
assertNotNull(crd.getJson().get("@context"));
});
}
private RunContext mockOB30Context(Resource res) {
ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
return new RunContext.Builder()
.put(new OB30Inspector.Builder().build())
.put(res)
.put(Key.JACKSON_OBJECTMAPPER, mapper)
.put(Key.JSONPATH_EVALUATOR, jsonPath)
.build();
}
}

View File

@ -1,6 +1,6 @@
package org.oneedtech.inspect.vc.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
import java.util.List;
@ -13,18 +13,31 @@ import org.oneedtech.inspect.vc.Samples;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
public class JsonNodeUtilTests {
static final ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
static final JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
@Test
void getEndorsementsTest() throws Exception {
assertDoesNotThrow(()->{
JsonNode root = mapper.readTree(Samples.OB30.JSON.COMPLETE_JSON.asBytes());
List<JsonNode> list = JsonNodeUtil.getEndorsements(root, jsonPath);
void testFlattenNodeList() {
Assertions.assertDoesNotThrow(()->{
String json = Samples.OB30.JSON.COMPLETE_JSON.asString();
JsonNode root = mapper.readTree(json);
List<JsonNode> list = JsonNodeUtil.asNodeList(root, "$..endorsement", jsonPath);
Assertions.assertEquals(5, list.size());
});
for(JsonNode node : list) {
ArrayNode types = (ArrayNode) node.get("type");
boolean found = false;
for(JsonNode val : types) {
if(val.asText().equals("EndorsementCredential")) {
found = true;
}
}
assertTrue(found);
}
});
}
}

View File

@ -0,0 +1,37 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://imsglobal.github.io/openbadges-specification/context.json",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"id": "http://example.edu/credentials/3732",
"type": [
"VerifiableCredential",
"OpenBadgeCredential"
],
"issuer": {
"id": "https://example.edu/issuers/565049",
"type": [
"Profile"
],
"name": "Example University"
},
"issuanceDate": "2010-01-01T00:00:00Z",
"expirationDate": "2020-01-20T00:00:00Z",
"name": "Example University Degree",
"credentialSubject": {
"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"type": [
"AchievementSubject"
]
},
"proof": [
{
"type": "Ed25519Signature2020",
"created": "2022-06-28T16:28:36Z",
"verificationMethod": "did:key:z6MkkUD3J14nkYzn46QeuaVSnp7dF85QJKwKvJvfsjx79aXj",
"proofPurpose": "assertionMethod",
"proofValue": "z3MUt2ZuU8Byqivxh6GphEM65AFYyNaGYibm97xLTafM7uGufZQLKvJR8itZwxKskvtFM3CUty46v26DZidMNoQnM"
}
]
}

View File

@ -0,0 +1,37 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://imsglobal.github.io/openbadges-specification/context.json",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"id": "http://example.edu/credentials/3732",
"type": [
"VerifiableCredential",
"OpenBadgeCredential"
],
"issuer": {
"id": "https://example.edu/issuers/565049",
"type": [
"Profile"
],
"name": "Example University"
},
"issuanceDate": "2040-01-01T00:00:00Z",
"expirationDate": "2050-01-20T00:00:00Z",
"name": "Example University Degree",
"credentialSubject": {
"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"type": [
"AchievementSubject"
]
},
"proof": [
{
"type": "Ed25519Signature2020",
"created": "2022-06-28T16:28:36Z",
"verificationMethod": "did:key:z6MkkUD3J14nkYzn46QeuaVSnp7dF85QJKwKvJvfsjx79aXj",
"proofPurpose": "assertionMethod",
"proofValue": "z3MUt2ZuU8Byqivxh6GphEM65AFYyNaGYibm97xLTafM7uGufZQLKvJR8itZwxKskvtFM3CUty46v26DZidMNoQnM"
}
]
}

View File

@ -0,0 +1,36 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://imsglobal.github.io/openbadges-specification/context.json",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"id": "http://example.edu/credentials/3732",
"type": [
"VerifiableCredential",
"OpenBadgeCredential"
],
"issuer": {
"id": "https://example.edu/issuers/565049",
"type": [
"Profile"
],
"name": "Example University"
},
"issuanceDate": "2010-01-01T00:00:00Z",
"name": "Example University Degree",
"credentialSubject": {
"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"type": [
"AchievementSubject"
]
},
"proof": [
{
"type": "Ed25519Signature2020",
"created": "2022-06-28T16:28:36Z",
"verificationMethod": "did:key:z6MkkUD3J14nkYzn46QeuaVSnp7dF85QJKwKvJvfsjx79aXj",
"proofPurpose": "assertionMethod",
"proofValue": "XXXz3MUt2ZuU8Byqivxh6GphEM65AFYyNaGYibm97xLTafM7uGufZQLKvJR8itZwxKskvtFM3CUty46v26DZidMNoQnMXXX"
}
]
}