diff --git a/inspector-vc/pom.xml b/inspector-vc/pom.xml
index 6881751..f253160 100644
--- a/inspector-vc/pom.xml
+++ b/inspector-vc/pom.xml
@@ -10,6 +10,31 @@
org.1edtech
inspector-core
+
+
+ org.bouncycastle
+ bcprov-jdk15to18
+ 1.65
+
+
+ com.auth0
+ auth0
+ 1.20.0
+
+
+ com.auth0
+ jwks-rsa
+ 0.12.0
+
+
+ com.auth0
+ java-jwt
+ 3.10.3
+
+
+ org.springframework
+ spring-core
+ 5.0.12.RELEASE
\ No newline at end of file
diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Credential.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Credential.java
index 63205c8..3e73655 100644
--- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Credential.java
+++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Credential.java
@@ -25,8 +25,9 @@ public class Credential extends GeneratedObject {
final Resource resource;
final JsonNode jsonData;
final Credential.Type credentialType;
+ final String jwt;
- public Credential(Resource resource, JsonNode data) {
+ public Credential(Resource resource, JsonNode data, String jwt) {
super(ID, GeneratedObject.Type.INTERNAL);
checkNotNull(resource, resource.getType(), data);
ResourceType type = resource.getType();
@@ -34,10 +35,10 @@ public class Credential extends GeneratedObject {
"Unrecognized payload type: " + type.getName());
this.resource = resource;
this.jsonData = data;
+ this.jwt = jwt;
ArrayNode typeNode = (ArrayNode)jsonData.get("type");
this.credentialType = Credential.Type.valueOf(typeNode);
-
}
public Resource getResource() {
@@ -51,6 +52,10 @@ public class Credential extends GeneratedObject {
public Credential.Type getCredentialType() {
return credentialType;
}
+
+ public String getJwt() {
+ return jwt;
+ }
/**
* Get the canonical schema for this credential if such exists.
diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB30Inspector.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB30Inspector.java
index f2fd220..d68c7e8 100644
--- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB30Inspector.java
+++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB30Inspector.java
@@ -6,6 +6,7 @@ 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 static com.google.common.base.Strings.isNullOrEmpty;
import java.net.URI;
import java.util.ArrayList;
@@ -105,15 +106,20 @@ public class OB30Inspector extends VCInspector {
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);
+ //If this credential was originally contained in a JWT we must validate the jwt and external proof.
+ if(!isNullOrEmpty(crd.getJwt())){
+ 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);
+ //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);
+ }
//check refresh service if we are not already refreshed
probeCount++;
@@ -147,8 +153,11 @@ public class OB30Inspector extends VCInspector {
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)));
+ //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)));
}
}
diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/CredentialTypeProbe.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/CredentialTypeProbe.java
index 517116e..535c9c0 100644
--- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/CredentialTypeProbe.java
+++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/CredentialTypeProbe.java
@@ -2,12 +2,16 @@ package org.oneedtech.inspect.vc.probe;
import static java.nio.charset.StandardCharsets.UTF_8;
+import java.io.ByteArrayInputStream;
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;
@@ -22,6 +26,8 @@ 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;
@@ -44,13 +50,13 @@ public class CredentialTypeProbe extends Probe {
if(type.isPresent()) {
resource.setType(type.get());
if(type.get() == ResourceType.PNG) {
- crd = new Credential(resource, fromPNG(resource, context));
+ crd = fromPNG(resource, context);
} else if(type.get() == ResourceType.SVG) {
- crd = new Credential(resource, fromSVG(resource, context));
+ crd = fromSVG(resource, context);
} else if(type.get() == ResourceType.JSON) {
- crd = new Credential(resource, fromJson(resource, context));
+ crd = fromJson(resource, context);
} else if(type.get() == ResourceType.JWT) {
- crd = new Credential(resource, fromJWT(resource, context));
+ crd = fromJWT(resource, context);
}
}
@@ -71,12 +77,39 @@ public class CredentialTypeProbe extends Probe {
* @param context
* @throws Exception
*/
- private JsonNode fromPNG(Resource resource, RunContext context) throws Exception {
- //TODO @Miles - note: iTxt chunk is either plain json or jwt
+ 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);
}
- return null;
}
/**
@@ -84,8 +117,10 @@ public class CredentialTypeProbe extends Probe {
* @param context
* @throws Exception
*/
- private JsonNode fromSVG(Resource resource, RunContext 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()) {
@@ -93,7 +128,8 @@ public class CredentialTypeProbe extends Probe {
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());
+ jwtString = verifyAttr.getValue();
+ credential = decodeJWT(verifyAttr.getValue(), context);
break;
} else {
while(reader.hasNext()) {
@@ -105,58 +141,90 @@ public class CredentialTypeProbe extends Probe {
Characters chars = ev.asCharacters();
if(!chars.isWhiteSpace()) {
json = chars.getData();
+ credential = buildNodeFromString(json, context);
break;
}
}
- }
- }
- }
- if(json!=null) break;
+ }
+ }
+ }
+ if(credential!=null) break;
}
}
- if(json == null) throw new IllegalArgumentException("No credential inside SVG");
- return fromString(json, context);
+ if(credential == null) throw new IllegalArgumentException("No credential inside SVG");
+ return new Credential(resource, credential, jwtString);
}
/**
- * Create a JsonNode object from a raw JSON resource.
+ * Create a Credential 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);
+ 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 fromString(String json, RunContext context) throws Exception {
+ private JsonNode buildNodeFromString(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) {
+ private JsonNode decodeJWT(String jwt, RunContext context) throws Exception {
List 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)));
+ //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)));
- String jwsSignature = new String(decoder.decode(parts.get(2)));
-
- //TODO @Miles
-
- return null;
+
+ //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");
diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ProofVerifierProbe.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ProofVerifierProbe.java
index e506aec..1618148 100644
--- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ProofVerifierProbe.java
+++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ProofVerifierProbe.java
@@ -1,5 +1,15 @@
package org.oneedtech.inspect.vc.probe;
+import java.security.KeyFactory;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+
+import org.bouncycastle.asn1.edec.EdECObjectIdentifiers;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+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;
@@ -23,5 +33,22 @@ public class ProofVerifierProbe extends Probe {
return success(ctx);
}
+ public boolean validate(String pubkey, String signature, String timestamp, String message) throws Exception {
+ //TODO: continue this implementation.
+ //Pulled in bouncy castle library and made sure this sample compiled only.
+ final var provider = new BouncyCastleProvider();
+ Security.addProvider(provider);
+ final var byteKey = Hex.decode(pubkey);
+ final var pki = new SubjectPublicKeyInfo(new AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519), byteKey);
+ 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(timestamp.getBytes());
+ signedData.update(message.getBytes());
+ return signedData.verify(Hex.decode(signature));
+ }
+
public static final String ID = ProofVerifierProbe.class.getSimpleName();
}
diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/SignatureVerifierProbe.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/SignatureVerifierProbe.java
index d9961fa..e8c06ae 100644
--- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/SignatureVerifierProbe.java
+++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/SignatureVerifierProbe.java
@@ -1,9 +1,34 @@
package org.oneedtech.inspect.vc.probe;
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+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 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.springframework.util.Base64Utils;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.exceptions.AlgorithmMismatchException;
+import com.auth0.jwt.exceptions.InvalidClaimException;
+import com.auth0.jwt.exceptions.SignatureVerificationException;
+import com.auth0.jwt.exceptions.TokenExpiredException;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Splitter;
/**
* A Probe that verifies credential signatures
@@ -17,11 +42,74 @@ public class SignatureVerifierProbe extends Probe {
@Override
public ReportItems run(Credential crd, RunContext ctx) throws Exception {
-
- //TODO @Miles -- if sigs fail, report OutCome.Fatal
+ try {
+ verifySignature(crd);
+ } 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");
+ List 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();
+ JsonNode headerObj = mapper.readTree(joseHeader);
+
+ //MUST be "RS256"
+ JsonNode alg = headerObj.get("alg");
+ if(alg == null || !alg.textValue().equals("RS256")) { throw new Exception("alg must be present and must be 'RS256'"); }
+
+ //TODO: decoded jwt will check timestamps, but shall we explicitly break these out?
+
+ //Option 1, fetch directly from header
+ JsonNode jwk = headerObj.get("jwk");
+
+ //Option 2, fetch from a hosting url
+ JsonNode kid = headerObj.get("kid");
+
+ if(jwk == null && kid == null) { throw new Exception("Key must present in either jwk or kid value."); }
+ if(kid != null){
+ //TODO @Miles load jwk JsonNode from url and do the rest the same below. Need to set up testing.
+ String kidUrl = kid.textValue();
+ }
+
+ //Clean up may be required. Currently need to cleanse extra double quoting.
+ 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));
+
+ PublicKey pub = KeyFactory.getInstance("RSA").generatePublic(new RSAPublicKeySpec(modulus, exponent));
+
+ Algorithm algorithm = Algorithm.RSA256((RSAPublicKey)pub, null);
+ JWTVerifier verifier = JWT.require(algorithm)
+ .build(); //Reusable verifier instance
+ try {
+ decodedJwt = verifier.verify(jwt);
+ }
+ catch(SignatureVerificationException ex){
+ throw new Exception("JWT Invalid signature", ex);
+ }
+ catch(AlgorithmMismatchException ex){
+ throw new Exception("JWT Algorithm mismatch", ex);
+ }
+ catch(TokenExpiredException ex){
+ throw new Exception("JWT Token expired", ex);
+ }
+ catch(InvalidClaimException ex){
+ throw new Exception("JWT, one or more claims are invalid", ex);
+ }
+ }
public static final String ID = SignatureVerifierProbe.class.getSimpleName();
diff --git a/inspector-vc/src/test/java/org/oneedtech/inspect/vc/OB30Tests.java b/inspector-vc/src/test/java/org/oneedtech/inspect/vc/OB30Tests.java
index 1083aed..4426250 100644
--- a/inspector-vc/src/test/java/org/oneedtech/inspect/vc/OB30Tests.java
+++ b/inspector-vc/src/test/java/org/oneedtech/inspect/vc/OB30Tests.java
@@ -32,7 +32,6 @@ public class OB30Tests {
});
}
- @Disabled
@Test
void testSimplePNGPlainValid() {
assertDoesNotThrow(()->{
@@ -42,7 +41,6 @@ public class OB30Tests {
});
}
- @Disabled
@Test
void testSimplePNGJWTValid() {
assertDoesNotThrow(()->{
diff --git a/inspector-vc/src/test/resources/ob30/simple-jwt.png b/inspector-vc/src/test/resources/ob30/simple-jwt.png
index 47e5afc..84a9ab2 100644
Binary files a/inspector-vc/src/test/resources/ob30/simple-jwt.png and b/inspector-vc/src/test/resources/ob30/simple-jwt.png differ
diff --git a/inspector-vc/src/test/resources/ob30/simple-jwt.svg b/inspector-vc/src/test/resources/ob30/simple-jwt.svg
index 8fb9094..960cf17 100644
--- a/inspector-vc/src/test/resources/ob30/simple-jwt.svg
+++ b/inspector-vc/src/test/resources/ob30/simple-jwt.svg
@@ -1,7 +1,7 @@