initial cut of ob3 verifier
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package org.oneedtech.inspect.vc;
|
||||
|
||||
import static org.oneedtech.inspect.util.code.Defensives.*;
|
||||
import static org.oneedtech.inspect.util.resource.ResourceType.*;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.oneedtech.inspect.core.probe.GeneratedObject;
|
||||
import org.oneedtech.inspect.schema.Catalog;
|
||||
import org.oneedtech.inspect.schema.SchemaKey;
|
||||
import org.oneedtech.inspect.util.resource.Resource;
|
||||
import org.oneedtech.inspect.util.resource.ResourceType;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.google.common.base.MoreObjects;
|
||||
|
||||
/**
|
||||
* A wrapper object for a verifiable credential. This contains e.g. the origin resource
|
||||
* and the extracted JSON data plus any other stuff Probes need.
|
||||
* @author mgylling
|
||||
*/
|
||||
public class Credential extends GeneratedObject {
|
||||
final Resource resource;
|
||||
final JsonNode jsonData;
|
||||
final Credential.Type credentialType;
|
||||
|
||||
public Credential(Resource resource, JsonNode data) {
|
||||
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;
|
||||
|
||||
ArrayNode typeNode = (ArrayNode)jsonData.get("type");
|
||||
this.credentialType = Credential.Type.valueOf(typeNode);
|
||||
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public JsonNode asJson() {
|
||||
return jsonData;
|
||||
}
|
||||
|
||||
public Credential.Type getCredentialType() {
|
||||
return credentialType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical schema for this credential if such exists.
|
||||
*/
|
||||
public Optional<SchemaKey> getSchemaKey() {
|
||||
if(credentialType == Credential.Type.AchievementCredential) {
|
||||
return Optional.of(Catalog.OB_30_ACHIEVEMENTCREDENTIAL_JSON);
|
||||
} else if(credentialType == Credential.Type.VerifiablePresentation) {
|
||||
return Optional.of(Catalog.OB_30_VERIFIABLEPRESENTATION_JSON);
|
||||
} else if(credentialType == Credential.Type.EndorsementCredential) {
|
||||
return Optional.of(Catalog.OB_30_ENDORSEMENTCREDENTIAL_JSON);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
AchievementCredential,
|
||||
OpenBadgeCredential, //treated as an alias of AchievementCredential
|
||||
EndorsementCredential,
|
||||
VerifiablePresentation,
|
||||
VerifiableCredential, //this is an underspecifier in our context
|
||||
Unknown;
|
||||
|
||||
public static Credential.Type valueOf (ArrayNode typeArray) {
|
||||
if(typeArray != null) {
|
||||
Iterator<JsonNode> iter = typeArray.iterator();
|
||||
while(iter.hasNext()) {
|
||||
String value = iter.next().asText();
|
||||
if(value.equals("AchievementCredential") || value.equals("OpenBadgeCredential")) {
|
||||
return AchievementCredential;
|
||||
} else if(value.equals("VerifiablePresentation")) {
|
||||
return VerifiablePresentation;
|
||||
} else if(value.equals("EndorsementCredential")) {
|
||||
return EndorsementCredential;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return MoreObjects.toStringHelper(this)
|
||||
.add("resource", resource.getID())
|
||||
.add("resourceType", resource.getType())
|
||||
.add("credentialType", credentialType)
|
||||
.add("json", jsonData)
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static final String ID = Credential.class.getCanonicalName();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.oneedtech.inspect.vc;
|
||||
|
||||
import static org.oneedtech.inspect.core.probe.RunContext.Key.*;
|
||||
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.oneedtech.inspect.core.SubInspector;
|
||||
import org.oneedtech.inspect.core.probe.GeneratedObject;
|
||||
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.core.report.Report;
|
||||
import org.oneedtech.inspect.core.report.ReportItems;
|
||||
import org.oneedtech.inspect.util.json.ObjectMapperCache;
|
||||
import org.oneedtech.inspect.util.resource.Resource;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* An inspector for EndersementCredential objects.
|
||||
* @author mgylling
|
||||
*/
|
||||
public class EndorsementInspector extends VCInspector implements SubInspector {
|
||||
|
||||
protected <B extends VCInspector.Builder<?>> EndorsementInspector(B builder) {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Report run(Resource resource, Map<String, GeneratedObject> parentObjects) {
|
||||
/*
|
||||
* resource is the top-level credential that embeds the endorsement, we
|
||||
* expect parentObjects to provide a pointer to the JsonNode we should check
|
||||
*/
|
||||
Credential endorsement = (Credential) parentObjects.get(ENDORSEMENT_KEY);
|
||||
|
||||
ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
|
||||
JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
|
||||
|
||||
RunContext ctx = new RunContext.Builder()
|
||||
.put(this)
|
||||
.put(resource)
|
||||
.put(JACKSON_OBJECTMAPPER, mapper)
|
||||
.put(JSONPATH_EVALUATOR, jsonPath)
|
||||
.put(ENDORSEMENT_KEY, endorsement)
|
||||
.build();
|
||||
|
||||
System.err.println("TODO" + endorsement.toString());
|
||||
|
||||
return new Report(ctx, new ReportItems(), 1); //TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public <R extends Resource> Report run(R resource) {
|
||||
throw new IllegalStateException("must use #run(resource, map)");
|
||||
}
|
||||
|
||||
public static class Builder extends VCInspector.Builder<EndorsementInspector.Builder> {
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public EndorsementInspector build() {
|
||||
return new EndorsementInspector(this);
|
||||
}
|
||||
}
|
||||
|
||||
public static final String ENDORSEMENT_KEY = "ENDORSEMENT_KEY";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package org.oneedtech.inspect.vc;
|
||||
|
||||
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.EndorsementInspector.ENDORSEMENT_KEY;
|
||||
import static org.oneedtech.inspect.vc.util.JsonNodeUtil.getEndorsements;
|
||||
|
||||
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;
|
||||
import org.oneedtech.inspect.schema.JsonSchemaCache;
|
||||
import org.oneedtech.inspect.schema.SchemaKey;
|
||||
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.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.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 com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
/**
|
||||
* A verifier for Open Badges 3.0.
|
||||
* @author mgylling
|
||||
*/
|
||||
public class OB30Inspector extends VCInspector {
|
||||
protected final List<Probe<Credential>> userProbes;
|
||||
|
||||
protected OB30Inspector(OB30Inspector.Builder builder) {
|
||||
super(builder);
|
||||
this.userProbes = ImmutableList.copyOf(builder.probes);
|
||||
}
|
||||
|
||||
//https://docs.google.com/document/d/1_imUl2K-5tMib0AUxwA9CWb0Ap1b3qif0sXydih68J0/edit#
|
||||
//https://imsglobal.github.io/openbadges-specification/ob_v3p0.html#verificaton-and-validation
|
||||
|
||||
@Override
|
||||
public Report run(Resource resource) {
|
||||
super.check(resource);
|
||||
|
||||
if(getBehavior(RESET_CACHES_ON_RUN) == TRUE) JsonSchemaCache.reset();
|
||||
|
||||
ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
|
||||
JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
|
||||
|
||||
RunContext ctx = new RunContext.Builder()
|
||||
.put(this)
|
||||
.put(resource)
|
||||
.put(Key.JACKSON_OBJECTMAPPER, mapper)
|
||||
.put(Key.JSONPATH_EVALUATOR, jsonPath)
|
||||
.build();
|
||||
|
||||
List<ReportItems> accumulator = new ArrayList<>();
|
||||
int probeCount = 0;
|
||||
|
||||
try {
|
||||
//detect type (png, svg, json, jwt) and extract json data
|
||||
probeCount++;
|
||||
accumulator.add(new CredentialTypeProbe().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
|
||||
probeCount++;
|
||||
accumulator.add(new JsonArrayProbe(vcType).run(crd.asJson(), ctx));
|
||||
probeCount++;
|
||||
accumulator.add(new JsonArrayProbe(obType).run(crd.asJson(), 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.asJson(), ctx));
|
||||
|
||||
//validate against any inline schemas
|
||||
probeCount++;
|
||||
accumulator.add(new InlineJsonSchemaProbe().run(crd, ctx));
|
||||
|
||||
//verify signatures TODO @Miles
|
||||
probeCount++;
|
||||
accumulator.add(new SignatureVerifierProbe().run(crd, ctx));
|
||||
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
|
||||
|
||||
//verify proofs TODO @Miles
|
||||
probeCount++;
|
||||
accumulator.add(new ProofVerifierProbe().run(crd, ctx));
|
||||
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); //TODO fail = invalid
|
||||
if(newID.isPresent()) {
|
||||
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.asJson(), jsonPath);
|
||||
if(endorsements.size() > 0) {
|
||||
EndorsementInspector subInspector = new EndorsementInspector.Builder().build();
|
||||
for(JsonNode endorsementNode : endorsements) {
|
||||
probeCount++;
|
||||
Credential endorsement = new Credential(resource, endorsementNode);
|
||||
accumulator.add(subInspector.run(resource, Map.of(ENDORSEMENT_KEY, endorsement)));
|
||||
}
|
||||
}
|
||||
|
||||
//finally, run any user-added probes
|
||||
for(Probe<Credential> probe : userProbes) {
|
||||
probeCount++;
|
||||
accumulator.add(probe.run(crd, ctx));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
accumulator.add(onProbeException(Probe.ID.NO_UNCAUGHT_EXCEPTIONS, resource, e));
|
||||
}
|
||||
|
||||
return new Report(ctx, new ReportItems(accumulator), probeCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the AchievementCredential or EndorsementCredential has a “refreshService” property and the type of the
|
||||
* RefreshService object is “1EdTechCredentialRefresh”, you should fetch the refreshed credential from the URL
|
||||
* provided, then start the verification process over using the response as input. If the request fails,
|
||||
* the credential is invalid.
|
||||
*/
|
||||
private Optional<String> checkRefreshService(Credential crd, RunContext ctx) {
|
||||
//TODO
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
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
|
||||
public OB30Inspector build() {
|
||||
set(Specification.OB30);
|
||||
set(ResourceType.OPENBADGE);
|
||||
return new OB30Inspector(this);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.oneedtech.inspect.vc;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.oneedtech.inspect.core.Inspector;
|
||||
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.report.Report;
|
||||
import org.oneedtech.inspect.core.report.ReportItems;
|
||||
|
||||
/**
|
||||
* Abstract base for verifiable credentials inspectors/verifiers.
|
||||
* @author mgylling
|
||||
*/
|
||||
public abstract class VCInspector extends Inspector {
|
||||
|
||||
protected <B extends VCInspector.Builder<?>> VCInspector(B builder) {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
protected Report abort(RunContext ctx, List<ReportItems> accumulator, int probeCount) {
|
||||
return new Report(ctx, new ReportItems(accumulator), probeCount);
|
||||
}
|
||||
|
||||
protected boolean broken(List<ReportItems> accumulator) {
|
||||
for(ReportItems items : accumulator) {
|
||||
if(items.contains(Outcome.FATAL, Outcome.EXCEPTION, Outcome.NOT_RUN)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public abstract static class Builder<B extends VCInspector.Builder<B>> extends Inspector.Builder<B> {
|
||||
final List<Probe<Credential>> probes;
|
||||
|
||||
public Builder() {
|
||||
super();
|
||||
this.probes = new ArrayList<>();
|
||||
}
|
||||
|
||||
public VCInspector.Builder<B> add(Probe<Credential> probe) {
|
||||
probes.add(probe);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Base64;
|
||||
import java.util.Base64.Decoder;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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 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 {
|
||||
Optional<ResourceType> type = TypeDetector.detect(resource, true);
|
||||
|
||||
if(type.isPresent()) {
|
||||
resource.setType(type.get());
|
||||
if(type.get() == ResourceType.PNG) {
|
||||
crd = new Credential(resource, fromPNG(resource, context));
|
||||
} else if(type.get() == ResourceType.SVG) {
|
||||
crd = new Credential(resource, fromSVG(resource, context));
|
||||
} else if(type.get() == ResourceType.JSON) {
|
||||
crd = new Credential(resource, fromJson(resource, context));
|
||||
} else if(type.get() == ResourceType.JWT) {
|
||||
crd = new Credential(resource, 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 JsonNode fromPNG(Resource resource, RunContext context) throws Exception {
|
||||
//TODO @Miles - note: iTxt chunk is either plain json or jwt
|
||||
try(InputStream is = resource.asByteSource().openStream()) {
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the JSON data from a baked SVG credential.
|
||||
* @param context
|
||||
* @throws Exception
|
||||
*/
|
||||
private JsonNode fromSVG(Resource resource, RunContext context) throws Exception {
|
||||
String json = 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) {
|
||||
json = decodeJWT(verifyAttr.getValue());
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(json!=null) break;
|
||||
}
|
||||
}
|
||||
if(json == null) throw new IllegalArgumentException("No credential inside SVG");
|
||||
return fromString(json, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JsonNode object from a raw JSON resource.
|
||||
* @param context
|
||||
*/
|
||||
private JsonNode fromJson(Resource resource, RunContext context) throws Exception {
|
||||
return fromString(resource.asByteSource().asCharSource(UTF_8).read(), context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JsonNode object from a String.
|
||||
*/
|
||||
private JsonNode fromString(String json, RunContext context) throws Exception {
|
||||
return ((ObjectMapper)context.get(RunContext.Key.JACKSON_OBJECTMAPPER)).readTree(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JsonNode object from a JWT resource.
|
||||
* @param context
|
||||
*/
|
||||
private JsonNode fromJWT(Resource resource, RunContext context) throws Exception {
|
||||
return fromString(decodeJWT(resource.asByteSource().asCharSource(UTF_8).read()), context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode as per https://www.imsglobal.org/spec/ob/v3p0/#jwt-proof
|
||||
* @return The decoded JSON String
|
||||
*/
|
||||
private String decodeJWT(String jwt) {
|
||||
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)));
|
||||
String jwtPayload = new String(decoder.decode(parts.get(1)));
|
||||
String jwsSignature = new String(decoder.decode(parts.get(2)));
|
||||
|
||||
//TODO @Miles
|
||||
|
||||
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");
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
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 com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* A Probe that verifies a credential's expiration status
|
||||
* @author mgylling
|
||||
*/
|
||||
public class ExpirationVerifierProbe extends Probe<Credential> {
|
||||
|
||||
public ExpirationVerifierProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
|
||||
/*
|
||||
* If the AchievementCredential or EndorsementCredential has an “expirationDate” property
|
||||
* and the expiration date is prior to the current date, the credential has expired.
|
||||
*/
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now();
|
||||
JsonNode node = crd.asJson().get("expirationDate");
|
||||
if(node != null) {
|
||||
ZonedDateTime expirationDate = null;
|
||||
try {
|
||||
expirationDate = ZonedDateTime.parse(node.textValue());
|
||||
if (now.isAfter(expirationDate)) {
|
||||
return fatal("The credential has expired (expiration date was " + node.asText() + ").", ctx);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return exception("Error while checking expirationDate: " + e.getMessage(), ctx.getResource());
|
||||
}
|
||||
}
|
||||
return success(ctx);
|
||||
}
|
||||
|
||||
public static final String ID = ExpirationVerifierProbe.class.getSimpleName();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.oneedtech.inspect.core.probe.Probe;
|
||||
import org.oneedtech.inspect.core.probe.RunContext;
|
||||
import org.oneedtech.inspect.core.probe.json.JsonSchemaProbe;
|
||||
import org.oneedtech.inspect.core.report.ReportItems;
|
||||
import org.oneedtech.inspect.schema.SchemaKey;
|
||||
import org.oneedtech.inspect.vc.Credential;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
|
||||
/**
|
||||
* Detect inline schemas in a credential and run them.
|
||||
* @author mgylling
|
||||
*/
|
||||
public class InlineJsonSchemaProbe extends Probe<Credential> {
|
||||
private static final Set<String> types = Set.of("1EdTechJsonSchemaValidator2019");
|
||||
private final boolean skipCanonical = true;
|
||||
|
||||
public InlineJsonSchemaProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
List<ReportItems> accumulator = new ArrayList<>();
|
||||
Set<String> ioErrors = new HashSet<>();
|
||||
|
||||
// JsonPathEvaluator jsonPath = ctx.get(RunContext.Key.JSONPATH_EVALUATOR);
|
||||
// ArrayNode nodes = jsonPath.eval("$..*[?(@.credentialSchema)]", crd.getJson());
|
||||
// note - we dont get deep nested ones in e.g. EndorsementCredential
|
||||
|
||||
JsonNode credentialSchemaNode = crd.asJson().get("credentialSchema");
|
||||
if(credentialSchemaNode == null) return success(ctx);
|
||||
|
||||
ArrayNode schemas = (ArrayNode) credentialSchemaNode; //TODO guard this cast
|
||||
|
||||
for(JsonNode schemaNode : schemas) {
|
||||
JsonNode typeNode = schemaNode.get("type");
|
||||
if(typeNode == null || !types.contains(typeNode.asText())) continue;
|
||||
JsonNode idNode = schemaNode.get("id");
|
||||
if(idNode == null) continue;
|
||||
String id = idNode.asText().strip();
|
||||
if(ioErrors.contains(id)) continue;
|
||||
if(skipCanonical && equals(crd.getSchemaKey(), id)) continue;
|
||||
try {
|
||||
accumulator.add(new JsonSchemaProbe(id).run(crd.asJson(), ctx));
|
||||
} catch (Exception e) {
|
||||
if(!ioErrors.contains(id)) {
|
||||
ioErrors.add(id);
|
||||
accumulator.add(error("Could not read schema resource " + id, ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReportItems(accumulator);
|
||||
}
|
||||
|
||||
private boolean equals(Optional<SchemaKey> key, String id) {
|
||||
return key.isPresent() && key.get().getCanonicalURI().equals(id);
|
||||
}
|
||||
|
||||
public static final String ID = InlineJsonSchemaProbe.class.getSimpleName();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
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 com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* A Probe that verifies a credential's issuance status
|
||||
* @author mgylling
|
||||
*/
|
||||
public class IssuanceVerifierProbe extends Probe<Credential> {
|
||||
|
||||
public IssuanceVerifierProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
|
||||
/*
|
||||
* If the AchievementCredential or EndorsementCredential “issuanceDate” property after
|
||||
* the current date, the credential is not yet valid.
|
||||
*/
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now();
|
||||
JsonNode node = crd.asJson().get("issuanceDate");
|
||||
if(node != null) {
|
||||
ZonedDateTime issuanceDate = null;
|
||||
try {
|
||||
issuanceDate = ZonedDateTime.parse(node.textValue());
|
||||
if (issuanceDate.isAfter(now)) {
|
||||
return fatal("The credential is not yet valid (issuance date is " + node.asText() + ").", ctx);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return exception("Error while checking issuanceDate: " + e.getMessage(), ctx.getResource());
|
||||
}
|
||||
}
|
||||
return success(ctx);
|
||||
}
|
||||
|
||||
public static final String ID = IssuanceVerifierProbe.class.getSimpleName();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* A Probe that verifies credential proofs
|
||||
* @author mlyon
|
||||
*/
|
||||
public class ProofVerifierProbe extends Probe<Credential> {
|
||||
|
||||
public ProofVerifierProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
|
||||
//TODO @Miles -- if proofs fail, report OutCome.Fatal
|
||||
|
||||
return success(ctx);
|
||||
}
|
||||
|
||||
public static final String ID = ProofVerifierProbe.class.getSimpleName();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
import static org.oneedtech.inspect.core.probe.RunContext.Key.JACKSON_OBJECTMAPPER;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
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.ObjectMapper;
|
||||
|
||||
/**
|
||||
* A Probe that verifies a credential's revocation status.
|
||||
* @author mgylling
|
||||
*/
|
||||
public class RevocationListProbe extends Probe<Credential> {
|
||||
|
||||
public RevocationListProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
|
||||
/*
|
||||
* If the AchievementCredential or EndorsementCredential has a “credentialStatus” property
|
||||
* and the type of the CredentialStatus object is “1EdTechRevocationList”, fetch the
|
||||
* credential status from the URL provided. If the request is unsuccessful,
|
||||
* report a warning, not an error.
|
||||
*/
|
||||
|
||||
JsonNode credentialStatus = crd.asJson().get("credentialStatus");
|
||||
if(credentialStatus != null) {
|
||||
JsonNode type = credentialStatus.get("type");
|
||||
if(type != null && type.asText().strip().equals("1EdTechRevocationList")) {
|
||||
JsonNode listID = credentialStatus.get("id");
|
||||
if(listID != null) {
|
||||
try {
|
||||
URL url = new URI(listID.asText().strip()).toURL();
|
||||
try (InputStream is = url.openStream()) {
|
||||
JsonNode revocList = ((ObjectMapper)ctx.get(JACKSON_OBJECTMAPPER)).readTree(is.readAllBytes());
|
||||
|
||||
/* To check if a credential has been revoked, the verifier issues a GET request
|
||||
* to the URL of the issuer's 1EdTech Revocation List Status Method. If the
|
||||
* credential's id is in the list of revokedCredentials and the value of
|
||||
* revoked is true or ommitted, the issuer has revoked the credential. */
|
||||
|
||||
JsonNode crdID = crd.asJson().get("id");
|
||||
if(crdID != null) {
|
||||
List<JsonNode> list = JsonNodeUtil.asNodeList(revocList.get("revokedCredentials"));
|
||||
if(list != null) {
|
||||
for(JsonNode item : list) {
|
||||
JsonNode revID = item.get("id");
|
||||
JsonNode revoked = item.get("revoked");
|
||||
if(revID != null && revID.equals(crdID) && (revoked == null || revoked.asBoolean())) {
|
||||
return fatal("Credential has been revoked", ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return warning("Error when fetching credentialStatus resource " + e.getMessage(), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return success(ctx);
|
||||
}
|
||||
|
||||
public static final String ID = RevocationListProbe.class.getSimpleName();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.oneedtech.inspect.vc.probe;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* A Probe that verifies credential signatures
|
||||
* @author mlyon
|
||||
*/
|
||||
public class SignatureVerifierProbe extends Probe<Credential> {
|
||||
|
||||
public SignatureVerifierProbe() {
|
||||
super(ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
|
||||
|
||||
//TODO @Miles -- if sigs fail, report OutCome.Fatal
|
||||
|
||||
return success(ctx);
|
||||
}
|
||||
|
||||
public static final String ID = SignatureVerifierProbe.class.getSimpleName();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.oneedtech.inspect.vc.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import org.oneedtech.inspect.core.probe.json.JsonPathEvaluator;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
|
||||
/**
|
||||
* Node access 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static List<String> asStringList(JsonNode node) {
|
||||
if(!(node instanceof ArrayNode)) {
|
||||
return List.of(node.asText());
|
||||
} else {
|
||||
ArrayNode arrayNode = (ArrayNode)node;
|
||||
return StreamSupport
|
||||
.stream(arrayNode.spliterator(), false)
|
||||
.map(n->n.asText().strip())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
public static List<JsonNode> asNodeList(JsonNode node) {
|
||||
if(node == null) return null;
|
||||
if(!(node instanceof ArrayNode)) {
|
||||
return List.of(node);
|
||||
} else {
|
||||
ArrayNode arrayNode = (ArrayNode)node;
|
||||
return StreamSupport
|
||||
.stream(arrayNode.spliterator(), false)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user