diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/EmbeddedProofProbe.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/EmbeddedProofProbe.java index 4a90cec..5d402f1 100644 --- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/EmbeddedProofProbe.java +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/EmbeddedProofProbe.java @@ -2,6 +2,8 @@ package org.oneedtech.inspect.vc.probe; import java.io.StringReader; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.Charset; import java.util.List; import java.util.Optional; @@ -20,8 +22,11 @@ import com.apicatalog.multicodec.Multicodec.Codec; import info.weboftrust.ldsignatures.LdProof; import info.weboftrust.ldsignatures.verifier.Ed25519Signature2020LdVerifier; +import jakarta.json.JsonArray; import jakarta.json.JsonObject; +import jakarta.json.JsonString; import jakarta.json.JsonStructure; +import jakarta.json.JsonValue; /** * A Probe that verifies a credential's embedded proof. @@ -41,16 +46,18 @@ public class EmbeddedProofProbe extends Probe { @Override public ReportItems run(VerifiableCredential crd, RunContext ctx) throws Exception { - W3CVCHolder credentiaHolder = new W3CVCHolder(com.danubetech.verifiablecredentials.VerifiableCredential.fromJson(new StringReader(crd.getJson().toString()))); + W3CVCHolder credentialHolder = new W3CVCHolder(com.danubetech.verifiablecredentials.VerifiableCredential + .fromJson(new StringReader(crd.getJson().toString()))); - List proofs = credentiaHolder.getProofs(); + List proofs = credentialHolder.getProofs(); if (proofs == null || proofs.size() == 0) { return error("The verifiable credential is missing a proof.", ctx); } // get proof of standard type and purpose - Optional selectedProof = proofs.stream().filter(proof -> proof.isType("Ed25519Signature2020") && proof.getProofPurpose().equals("assertionMethod")) - .findFirst(); + Optional selectedProof = proofs.stream().filter( + proof -> proof.isType("Ed25519Signature2020") && proof.getProofPurpose().equals("assertionMethod")) + .findFirst(); if (!selectedProof.isPresent()) { return error("No proof with type \"Ed25519Signature2020\" or proof purpose \"assertionMethod\" found", ctx); @@ -70,13 +77,15 @@ public class EmbeddedProofProbe extends Probe { // The verification method must dereference to an Ed25519VerificationKey2020. // Danubetech's Ed25519Signature2020LdVerifier expects the decoded public key // from the Ed25519VerificationKey2020 (32 bytes). - // + // // Formats accepted: // // [controller]#[publicKeyMultibase] // did:key:[publicKeyMultibase] + // did:web:[url-encoded domain-name][:path]* // http/s://[location of a Ed25519VerificationKey2020 document] - // http/s://[location of a controller document with a 'verificationMethod' with a Ed25519VerificationKey2020] + // http/s://[location of a controller document with a 'verificationMethod' with + // a Ed25519VerificationKey2020] String publicKeyMultibase; String controller = null; @@ -92,12 +101,98 @@ public class EmbeddedProofProbe extends Probe { } else if (method.getScheme().equals("did")) { if (method.getSchemeSpecificPart().startsWith("key:")) { publicKeyMultibase = method.getSchemeSpecificPart().substring("key:".length()); + } else if (method.getSchemeSpecificPart().startsWith("web:")) { + String methodSpecificId = method.getRawSchemeSpecificPart().substring("web:".length()); + + // read algorithm at https://w3c-ccg.github.io/did-method-web/#read-resolve. + // Steps in comments + + // 1. Replace ":" with "/" in the method specific identifier to obtain the fully + // qualified domain name and optional path. + methodSpecificId = methodSpecificId.replaceAll(":", "/"); + + // 2. If the domain contains a port percent decode the colon. + String portPercentEncoded = URLEncoder.encode(":", Charset.forName("UTF-8")); + int index = methodSpecificId.indexOf(portPercentEncoded); + if (index >= 0 && index < methodSpecificId.indexOf("/")) { + methodSpecificId = methodSpecificId.replace(portPercentEncoded, ":"); + } + + // 3. Generate an HTTPS URL to the expected location of the DID document by + // prepending https://. + URI uri = new URI("https://" + methodSpecificId); + + // 4. If no path has been specified in the URL, append /.well-known. + if (uri.getPath() == null) { + uri = uri.resolve("/well-known"); + } + + // 5. Append /did.json to complete the URL. + uri = uri.resolve(uri.getPath() + "/did.json"); + + // 6. Perform an HTTP GET request to the URL using an agent that can + // successfully negotiate a secure HTTPS connection, which enforces the security + // requirements as described in 2.6 Security and privacy considerations. + // 7. When performing the DNS resolution during the HTTP GET request, the client + // SHOULD utilize [RFC8484] in order to prevent tracking of the identity being + // resolved. + Optional keyStructure; + try { + Document keyDocument = credentialHolder.getCredential().getDocumentLoader().loadDocument(uri, + new DocumentLoaderOptions()); + keyStructure = keyDocument.getJsonContent(); + } catch (Exception e) { + return error("Key document not found at " + method + ". URI: " + uri + + " doesn't return a valid document. Reason: " + e.getMessage() + " ", ctx); + } + if (keyStructure.isEmpty()) { + return error("Key document not found at " + method + ". URI: " + uri + + " doesn't return a valid document. Reason: The document is empty.", ctx); + } + + // check did in "assertionMethod" + JsonArray assertionMethod = keyStructure.get().asJsonObject() + .getJsonArray("assertionMethod"); + if (assertionMethod == null) { + return error("Document doesn't have a list of assertion methods at URI: " + uri, ctx); + } else { + Boolean anyMatch = false; + for(int i = 0; i < assertionMethod.size(); i++) { + String assertionMethodValue = assertionMethod.getString(i); + if (assertionMethodValue.equals(method.toString())) { + anyMatch = true; + break; + } + } + if (!anyMatch) { + return error("Assertion method " + method + " not found in DID document.", ctx); + } + } + + // get keys from "verificationMethod" + JsonArray keyVerificationMethod = keyStructure.get().asJsonObject() + .getJsonArray("verificationMethod"); + if (keyVerificationMethod == null) { + return error("Document doesn't have a list of verification methods at URI: " + uri, ctx); + } + Optional verificationMethodMaybe = keyVerificationMethod.stream() + .filter(n -> n.asJsonObject().getString("id").equals(method.toString())) + .findFirst(); + if (verificationMethodMaybe.isEmpty()) { + return error("Verification method " + method + " not found in DID document.", ctx); + } + JsonObject verificationMethod = verificationMethodMaybe.get().asJsonObject(); + // assuming a Ed25519VerificationKey2020 document + controller = verificationMethod.getString("controller"); + publicKeyMultibase = verificationMethod.getString("publicKeyMultibase"); + } else { return error("Unknown verification method: " + method, ctx); } } else if (method.getScheme().equals("http") || method.getScheme().equals("https")) { try { - Document keyDocument = credentiaHolder.getCredential().getDocumentLoader().loadDocument(method, new DocumentLoaderOptions()); + Document keyDocument = credentialHolder.getCredential().getDocumentLoader().loadDocument(method, + new DocumentLoaderOptions()); Optional keyStructure = keyDocument.getJsonContent(); if (keyStructure.isEmpty()) { return error("Key document not found at " + method, ctx); @@ -106,7 +201,8 @@ public class EmbeddedProofProbe extends Probe { // First look for a Ed25519VerificationKey2020 document controller = keyStructure.get().asJsonObject().getString("controller"); if (StringUtils.isBlank(controller)) { - // Then look for a controller document (e.g. DID Document) with a 'verificationMethod' + // Then look for a controller document (e.g. DID Document) with a + // 'verificationMethod' // that is a Ed25519VerificationKey2020 document JsonObject keyVerificationMethod = keyStructure.get().asJsonObject() .getJsonObject("verificationMethod"); @@ -140,8 +236,9 @@ public class EmbeddedProofProbe extends Probe { } if (controller != null) { - if (!controller.equals(credentiaHolder.getCredential().getIssuer().toString())) { - return error("Key controller does not match issuer: " + credentiaHolder.getCredential().getIssuer(), ctx); + if (!controller.equals(credentialHolder.getCredential().getIssuer().toString())) { + return error("Key controller does not match issuer: " + credentialHolder.getCredential().getIssuer(), + ctx); } } @@ -151,7 +248,7 @@ public class EmbeddedProofProbe extends Probe { Ed25519Signature2020LdVerifier verifier = new Ed25519Signature2020LdVerifier(publicKey); try { - boolean verify = verifier.verify(credentiaHolder.getCredential(), proof); + boolean verify = verifier.verify(credentialHolder.getCredential(), proof); if (!verify) { return error("Embedded proof verification failed.", ctx); } 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 2367fea..1ad10dc 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 @@ -44,9 +44,18 @@ public class OB30Tests { } @Test - void testSimpleDidMethodJsonValid() { + void testSimpleDidKeyMethodJsonValid() { assertDoesNotThrow(()->{ - Report report = validator.run(Samples.OB30.JSON.SIMPLE_DID_METHOD_JSON.asFileResource()); + Report report = validator.run(Samples.OB30.JSON.SIMPLE_DID_KEY_METHOD_JSON.asFileResource()); + if(verbose) PrintHelper.print(report, true); + assertValid(report); + }); + } + + @Test + void testSimpleDidWebMethodJsonValid() { + assertDoesNotThrow(()->{ + Report report = validator.run(Samples.OB30.JSON.SIMPLE_DID_WEB_METHOD_JSON.asFileResource()); if(verbose) PrintHelper.print(report, true); assertValid(report); }); diff --git a/inspector-vc/src/test/java/org/oneedtech/inspect/vc/Samples.java b/inspector-vc/src/test/java/org/oneedtech/inspect/vc/Samples.java index ab74a9e..b0f2607 100644 --- a/inspector-vc/src/test/java/org/oneedtech/inspect/vc/Samples.java +++ b/inspector-vc/src/test/java/org/oneedtech/inspect/vc/Samples.java @@ -11,7 +11,8 @@ public class Samples { public static final class JSON { 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_DID_METHOD_JSON = new Sample("ob30/simple-did-method.json", true); + public final static Sample SIMPLE_DID_KEY_METHOD_JSON = new Sample("ob30/simple-did-key-method.json", true); + public final static Sample SIMPLE_DID_WEB_METHOD_JSON = new Sample("ob30/simple-did-web-method.json", true); public final static Sample SIMPLE_MULTIPLE_PROOF_JSON = new Sample("ob30/simple-multiple-proofs.json", true); public final static Sample SIMPLE_JSON_NOPROOF = new Sample("ob30/simple-noproof.json", false); public final static Sample SIMPLE_JSON_UNKNOWN_TYPE = new Sample("ob30/simple-err-type.json", false); diff --git a/inspector-vc/src/test/resources/ob30/simple-did-method.json b/inspector-vc/src/test/resources/ob30/simple-did-key-method.json similarity index 100% rename from inspector-vc/src/test/resources/ob30/simple-did-method.json rename to inspector-vc/src/test/resources/ob30/simple-did-key-method.json diff --git a/inspector-vc/src/test/resources/ob30/simple-did-web-method.json b/inspector-vc/src/test/resources/ob30/simple-did-web-method.json new file mode 100644 index 0000000..718e0a2 --- /dev/null +++ b/inspector-vc/src/test/resources/ob30/simple-did-web-method.json @@ -0,0 +1,47 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context.json", + "https://purl.imsglobal.org/spec/ob/v3p0/extensions.json", + "https://w3id.org/security/suites/ed25519-2020/v1" + ], + "id": "http://dc.1edtech.org/wellspring2022/wellspring-portal/credentials/5d7b7ff6-b1d5-47d5-83df-faf22533ba8f", + "type": [ + "VerifiableCredential", + "AchievementCredential" + ], + "issuer": { + "id": "did:web:dc.1edtech.org:wellspring2022:wellspring-portal:org:7ad80b28-4f3f-414d-85ec-6c0684344e5c", + "type": "Profile", + "name": "Wellspring School" + }, + "awardedDate": "2023-05-16T11:27:00Z", + "issuanceDate": "2023-05-16T07:18:52Z", + "name": "Simple assertion of achievement 1", + "credentialSubject": { + "id": "did:web:dc.1edtech.org:wellspring2022:wellspring-portal:learner:d4e1f6ad-9696-41cb-8729-8ff741f96c6a", + "type": "AchievementSubject", + "achievement": { + "id": "http://dc.1edtech.org/wellspring2022/wellspring-portal/achievements/c44d8939-c237-4420-902c-9af305b15e2f", + "type": "Achievement", + "achievementType": "Achievement", + "criteria": { + "narrative": "The credential must pass verification." + }, + "description": "This is a test achievement.", + "name": "Achievement 1" + }, + "source": { + "id": "did:web:dc.1edtech.org:wellspring2022:wellspring-portal:org:7ad80b28-4f3f-414d-85ec-6c0684344e5c", + "type": "Profile", + "name": "Wellspring School" + } + }, + "proof": { + "type": "Ed25519Signature2020", + "created": "2023-05-16T07:18:52Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:dc.1edtech.org:wellspring2022:wellspring-portal:org:7ad80b28-4f3f-414d-85ec-6c0684344e5c#key-0", + "proofValue": "zYQ6iszNf2qCqisuWvk1AkerTTp69RiofNWWzWp4s5TJwzBfFgieBSA5Knyjco6crJbPkJ1mvM1hzA2HLYfU8w8C" + } +} \ No newline at end of file