diff --git a/.gitignore b/.gitignore index dec9f95c..11dcb067 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ .idea *.iml +.vscode/ diff --git a/package-lock.json b/package-lock.json index 3474c669..715104fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -660,9 +660,9 @@ } }, "@govtechsg/open-attestation": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@govtechsg/open-attestation/-/open-attestation-3.0.4.tgz", - "integrity": "sha512-QMPs7Y4CGq1yl2+gLh1LveY1blhgf2qbm0s0bP9gmmyKTI5uwstX5q2GsHiN7k0P7p5mqSIHbID2mhEM0cuc2A==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@govtechsg/open-attestation/-/open-attestation-3.1.0.tgz", + "integrity": "sha512-7QkanH/Q8CUor4gX5Be+jELh5uAdyiVtj5dxU0P8NMjwvtS+hEd/9Iz2do8Xwl8kDS0CYu9tjkLPIxq454dF4Q==", "requires": { "ajv": "6.10.2", "debug": "^4.1.1", @@ -692,11 +692,6 @@ "requires": { "ms": "^2.1.1" } - }, - "uuid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", - "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" } } }, @@ -1553,6 +1548,11 @@ "@babel/types": "^7.3.0" } }, + "@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -1646,6 +1646,37 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/request": { + "version": "2.48.4", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.4.tgz", + "integrity": "sha512-W1t1MTKYR8PxICH+A4HgEIPuAC3sbljoEVfyZbeFJJDbr30guDspJri2XOaM2E+Un7ZjrihaDi7cf6fPa2tbgw==", + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, + "@types/request-promise-native": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/request-promise-native/-/request-promise-native-1.0.17.tgz", + "integrity": "sha512-05/d0WbmuwjtGMYEdHIBZ0tqMJJQ2AD9LG2F6rKNBGX1SSFR27XveajH//2N/XYtual8T9Axwl+4v7oBtPUZqg==", + "requires": { + "@types/request": "*" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -1664,6 +1695,11 @@ "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/tough-cookie": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", + "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" + }, "@types/yargs": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.3.tgz", @@ -2106,8 +2142,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.2", @@ -2722,7 +2757,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -3322,8 +3356,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "deprecation": { "version": "2.3.1", @@ -7152,18 +7185,16 @@ "dev": true }, "mime-db": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", - "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==", - "dev": true + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==" }, "mime-types": { - "version": "2.1.25", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz", - "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==", - "dev": true, + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", "requires": { - "mime-db": "1.42.0" + "mime-db": "1.43.0" } }, "mimic-fn": { @@ -11391,8 +11422,7 @@ "psl": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.6.0.tgz", - "integrity": "sha512-SYKKmVel98NCOYXpkwUqZqh0ahZeeKfmisiLIcEZdsb+WbLv02g/dI5BUmZnIyOe7RzZtLax81nnb2HbvC2tzA==", - "dev": true + "integrity": "sha512-SYKKmVel98NCOYXpkwUqZqh0ahZeeKfmisiLIcEZdsb+WbLv02g/dI5BUmZnIyOe7RzZtLax81nnb2HbvC2tzA==" }, "pump": { "version": "3.0.0", @@ -11717,7 +11747,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", - "dev": true, "requires": { "lodash": "^4.17.15" } @@ -11726,7 +11755,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", - "dev": true, "requires": { "request-promise-core": "1.1.3", "stealthy-require": "^1.1.1", @@ -12756,8 +12784,7 @@ "stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "dev": true + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" }, "stream-combiner2": { "version": "1.1.1", @@ -13132,7 +13159,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "requires": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -13393,10 +13419,9 @@ } }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "dev": true + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" }, "v8-compile-cache": { "version": "2.1.0", diff --git a/package.json b/package.json index 551f268a..87298592 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "dependencies": { "@govtechsg/dnsprove": "^2.0.5", "@govtechsg/open-attestation": "3.1.0", - "ethers": "^4.0.40" + "@types/request-promise-native": "^1.0.17", + "ethers": "^4.0.40", + "request-promise-native": "^1.0.8" }, "devDependencies": { "@commitlint/cli": "8.2.0", diff --git a/src/config.ts b/src/config.ts index d66b6eea..2854ab12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1 +1,10 @@ export const INFURA_API_KEY = "92c9a51428b946c1b8c1ac5a237616e4"; + +// DID Universal Resolver parameters +export const BASE_RESOLVER_URL = "https://uniresolver.io"; +export const RESOLVER_VERSION = "1.0"; +export const RESOLVER_PATH = "identifiers"; + +// w3c-dids +export const ETHR_DID_METHOD = "ethr"; +export const SUPPORTED_DID_AUTH = ["Secp256k1SignatureAuthentication2018"]; diff --git a/src/types/w3c-did.ts b/src/types/w3c-did.ts new file mode 100644 index 00000000..578a2b08 --- /dev/null +++ b/src/types/w3c-did.ts @@ -0,0 +1,63 @@ +export interface Identity { + identified: true; + ethereumAddress: string; + smartContract: string; +} + +export interface DIDDocument { + "@context": "https://w3id.org/did/v1"; + id: string; + publicKey: PublicKey[]; + authentication?: Authentication[]; + uportProfile?: any; + service?: ServiceEndpoint[]; + created?: string; + updated?: string; + proof?: LinkedDataProof; +} + +export interface ServiceEndpoint { + id: string; + type: string; + serviceEndpoint: string; + description?: string; +} + +export interface PublicKey { + id: string; + type: string; + owner: string; + ethereumAddress?: string; + publicKeyBase64?: string; + publicKeyBase58?: string; + publicKeyHex?: string; + publicKeyPem?: string; +} + +export interface Authentication { + type: string; + publicKey: string[]; +} + +export interface LinkedDataProof { + type: string; + created: string; + creator: string; + nonce: string; + signatureValue: string; +} + +export interface Params { + [index: string]: string; +} + +export interface ParsedDID { + did: string; + didUrl: string; + method: string; + id: string; + path?: string; + fragment?: string; + query?: string; + params?: Params; +} diff --git a/src/verifiers/openAttestationW3CDID.test.ts b/src/verifiers/openAttestationW3CDID.test.ts new file mode 100644 index 00000000..c78ac650 --- /dev/null +++ b/src/verifiers/openAttestationW3CDID.test.ts @@ -0,0 +1,257 @@ +import { openAttestationW3CDID } from "./openAttestationW3CDID"; +import { documentRopstenValidWithIssuerDID } from "../../test/fixtures/v3/documentRopstenValidWithIssuerDID"; + +/** + * Note tests are quite slow due to external service reliance / internet connectivity. + * Interaction with public universal resolver and eth networks currently required. + * @todo + * - local units / mock out requests + */ +jest.setTimeout(30000); + +describe("OpenAttestationW3CDID verify v3 VALID document", () => { + it("should return a valid fragment when document has valid identity using W3CDID on ropsten", async () => { + const fragment = await openAttestationW3CDID.verify(documentRopstenValidWithIssuerDID, { + network: "ropsten" + }); + + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("VALID"); + expect(fragment.data).toStrictEqual({ + "@context": "https://w3id.org/did/v1", + id: "did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54405f90", + authentication: [ + { + type: "Secp256k1SignatureAuthentication2018", + publicKey: ["did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54405f90#owner"] + } + ], + publicKey: [ + { + id: "did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54405f90#owner", + type: "Secp256k1VerificationKey2018", + ethereumAddress: "0xb26b4941941c51a4885e5b7d3a1b861e54405f90", + owner: "did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54405f90" + } + ] + }); + }); +}); +describe("OpenAttestationW3CDID verify v3 ERROR document", () => { + it("should return an error fragment when document DID does not match the issuer account", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + location: + "1d337929-6770-4a05-ace0-1f07c25c7615:string:did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54400000" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "this DID does not appear to have issued the document"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + }); + it("should return an error fragment when document has not been issued", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + signature: { + ...documentRopstenValidWithIssuerDID.signature, + merkleRoot: "00000288dfdb20e7f9a62329adf1f3ad8eed0345a2c517ee7af3e9e88d02a5cd" + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "Document has not been issued..."; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + }); + it("should return an error fragment when no supported auth in didDoc, unsupported did-auth", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + // Change to sov did-method + location: "1d337929-6770-4a05-ace0-1f07c25c7615:string:did:sov:WRfXPg8dantKVubE3HX8pw" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "Issuer DID cannot be authenticated, no supported auth in didDoc"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + expect(String(fragment.message)).toContain(errorMsg); + }); + it("should return an error fragment when an unrecognized did-method presented, returning a 404 from the uni resolver", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + // Change to sov did-method + location: "1d337929-6770-4a05-ace0-1f07c25c7615:string:did:nodidmethod:1234" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "404"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + expect(String(fragment.message)).toContain(errorMsg); + }); + it("should return an error fragment when a malformed, using hyphens, did presented, returning a 500 currently from uni resolver", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + location: "1d337929-6770-4a05-ace0-1f07c25c7615:string:did-hyphens-ethr-0x1234" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "500"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + expect(String(fragment.message)).toContain(errorMsg); + }); + it("should return an error fragment when a malformed, random string, did presented, returning a 500 currently from uni resolver", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + location: "1d337929-6770-4a05-ace0-1f07c25c7615:string:didhyphensethr0x1234" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "500"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + expect(String(fragment.message)).toContain(errorMsg); + }); + it("should return an error fragment when an empty did presented, returning a 404 from uni resolver", async () => { + const fragment = await openAttestationW3CDID.verify( + { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + location: "1d337929-6770-4a05-ace0-1f07c25c7615:string:" + } + } + } + }, + { + network: "ropsten" + } + ); + + const errorMsg = "404"; + expect(fragment.type).toStrictEqual("ISSUER_IDENTITY"); + expect(fragment.name).toStrictEqual("OpenAttestationW3CDID"); + expect(fragment.status).toStrictEqual("ERROR"); + expect(String(fragment.data)).toContain(errorMsg); + expect(String(fragment.message)).toContain(errorMsg); + }); +}); +describe("OpenAttestationW3CDID test v3 VALID document", () => { + it("should return true if identityProof type is W3C-DID", () => { + expect( + openAttestationW3CDID.test(documentRopstenValidWithIssuerDID, { + network: "ropsten" + }) + ).toStrictEqual(true); + }); +}); +describe("OpenAttestationW3CDID test v3 INVALID document", () => { + it("should return false if identityProof type is not W3C-DID", () => { + const document = { + ...documentRopstenValidWithIssuerDID, + data: { + ...documentRopstenValidWithIssuerDID.data, + issuer: { + ...documentRopstenValidWithIssuerDID.data.issuer, + identityProof: { + ...documentRopstenValidWithIssuerDID.data.issuer.identityProof, + type: "whatever" + } + } + } + }; + expect( + openAttestationW3CDID.test(document, { + network: "ropsten" + }) + ).toStrictEqual(false); + }); +}); diff --git a/src/verifiers/openAttestationW3CDID.ts b/src/verifiers/openAttestationW3CDID.ts new file mode 100644 index 00000000..f2ff21be --- /dev/null +++ b/src/verifiers/openAttestationW3CDID.ts @@ -0,0 +1,70 @@ +import { getData, v3, WrappedDocument } from "@govtechsg/open-attestation"; +import { resolveDID } from "./w3c-did/resolveDID"; +import { ethrDidAuth } from "./w3c-did/ethrDidAuth"; +import { Verifier } from "../types/core"; +import { Authentication } from "../types/w3c-did"; +import { SUPPORTED_DID_AUTH, ETHR_DID_METHOD } from "../config"; + +const name = "OpenAttestationW3CDID"; +const type = "ISSUER_IDENTITY"; + +/** + * DID structure MUST comply with: https://www.w3.org/TR/did-core/ + * Authentication of a DID is defined based on the `did-method` + * Where the method will ALWAYS be did::, as per above spec + * @todo + * - Determine if a standard format to be used for the `data` field within returned fragments. + * Currently the didDoc is returned as the `data` field. + */ +export const openAttestationW3CDID: Verifier> = { + skip: () => { + return Promise.resolve({ + status: "SKIPPED", + type, + name, + message: `Document issuer doesn't doesn't use ${v3.IdentityProofType.W3CDid} type` + }); + }, + test: document => { + const documentData = getData(document); + return documentData.issuer.identityProof.type === v3.IdentityProofType.W3CDid; + }, + verify: async (document, options) => { + try { + const { issuer } = getData(document); + const did = issuer?.identityProof?.location; + const didDoc = await resolveDID(did); + + // If the didDoc has an auth method we currently support + const supportedAuth = didDoc?.authentication?.filter((auth: Authentication) => + SUPPORTED_DID_AUTH.includes(auth.type) + ); + + if (!supportedAuth?.length) throw new Error("Issuer DID cannot be authenticated, no supported auth in didDoc."); + + const didMethod = did.split(":")[1]; + + let authenticated = false; + if (didMethod === ETHR_DID_METHOD) { + authenticated = await ethrDidAuth(didDoc, document, supportedAuth, options); + } else { + throw new Error(`${didMethod} is currently not supported...`); + } + + const data = didDoc; + + if (authenticated) { + const status = "VALID"; + return { name, type, data, status }; + } + const status = "INVALID"; + const message = "Certificate issuer identity is invalid"; + return { name, type, data, status, message }; + } catch (e) { + const data = e; + const { message } = e; + const status = "ERROR"; + return { name, type, data, message, status }; + } + } +}; diff --git a/src/verifiers/w3c-did/ethrDidAuth.ts b/src/verifiers/w3c-did/ethrDidAuth.ts new file mode 100644 index 00000000..9455e724 --- /dev/null +++ b/src/verifiers/w3c-did/ethrDidAuth.ts @@ -0,0 +1,48 @@ +import { getData, v3, WrappedDocument } from "@govtechsg/open-attestation"; +import { DIDDocument, Authentication, PublicKey } from "../../types/w3c-did"; +import { VerificationManagerOptions } from "../../types/core"; +import { getDocumentStoreSmartContract } from "../../common/smartContract/documentToSmartContracts"; +import { getDocumentIssuer } from "./getDocumentIssuer"; + +/** + * Currently if the ethereumAddress that is associated with one of the authentication mechanisms' public keys + * defined in the didDoc matches the tx.origin that issued the cert then the issuer is authenticated. + * @todo + * - Consider other auth flows such as comparing against the owner of the document store as well as adding + * signatures around the cert itself. + * - Note authentication is currently dependent on retrieving the issuance transaction from the specified eth network. + * - Consider mutli-sig issuance use cases. + */ +export const ethrDidAuth = async ( + didDoc: DIDDocument, + document: WrappedDocument, + supportedAuth: Authentication[], + options: VerificationManagerOptions +): Promise => { + const smartContracts = getDocumentStoreSmartContract(document, options); + const { proof } = getData(document); + const txSender = await getDocumentIssuer(smartContracts[0].instance, proof.value, document, options.network); + + // Transform publicKey array to object with id as key for efficient lookup + const publicKeys: { [key: string]: PublicKey } = {}; + didDoc.publicKey.forEach((key: PublicKey) => { + publicKeys[key.id] = key; + }); + + // Promisified to resolve as soon as a match is found + return new Promise((resolve, reject) => { + supportedAuth.forEach((auth: Authentication) => { + const pubKeyURIs = auth.publicKey; // This is an array... + if (pubKeyURIs?.length) { + // Retrieve actual pubKey objects from didDoc + pubKeyURIs.forEach((keyId: string) => { + const ethAddress = publicKeys[keyId].ethereumAddress?.toLowerCase() ?? ""; + if (txSender === ethAddress) { + resolve(true); + } + }); + } + }); + reject(new Error("Identity could not be authenticated, this DID does not appear to have issued the document.")); + }); +}; diff --git a/src/verifiers/w3c-did/getDocumentIssuer.ts b/src/verifiers/w3c-did/getDocumentIssuer.ts new file mode 100644 index 00000000..8d76656f --- /dev/null +++ b/src/verifiers/w3c-did/getDocumentIssuer.ts @@ -0,0 +1,37 @@ +import * as ethers from "ethers"; +import { v3, WrappedDocument } from "@govtechsg/open-attestation"; +import { INFURA_API_KEY } from "../../config"; +import { getIssuedBlock } from "./getIssuedBlock"; + +/** + * Retrieve the account that sent the transaction to issue this document. + * This is the address that will be used to authenticate the didDoc against. + * Get the event log and then pull the tx.origin from the transaction receipt. + */ +export const getDocumentIssuer = async ( + contract: ethers.Contract, + documentStoreAddress: string, + document: WrappedDocument, + network: string +): Promise => { + const provider = new ethers.providers.InfuraProvider(network, INFURA_API_KEY); + const documentHash = `0x${document.signature.merkleRoot}`; + const { topics } = contract.filters.DocumentIssued(documentHash); + const fromBlock = await getIssuedBlock(contract, documentHash); + const toBlock = fromBlock; + const address = documentStoreAddress; + + return new Promise((resolve, reject) => { + provider.getLogs({ fromBlock, toBlock, address, topics }).then(async (result: any) => { + if (result.length === 1) { + const tx = await provider.getTransactionReceipt(result[0].transactionHash); + resolve(tx?.from?.toLowerCase()); + // Sanity checks... should NOT be possible to hit these... issued twice or no event emitted + } else if (result.length > 1) { + reject(new Error(`There is more than one event for the issuance of this document: ${documentHash}`)); + } else { + reject(new Error("No logs found for the issuance of this document...")); + } + }); + }); +}; diff --git a/src/verifiers/w3c-did/getIssuedBlock.ts b/src/verifiers/w3c-did/getIssuedBlock.ts new file mode 100644 index 00000000..edc4c06d --- /dev/null +++ b/src/verifiers/w3c-did/getIssuedBlock.ts @@ -0,0 +1,10 @@ +import { Contract } from "ethers"; + +// Cleaning up the error message if the document has not been issued. +export const getIssuedBlock = async (contract: Contract, documentHash: string) => { + try { + return (await contract.getIssuedBlock(documentHash)).toNumber(); + } catch (e) { + throw new Error(`Document has not been issued... ${documentHash}`); + } +}; diff --git a/src/verifiers/w3c-did/resolveDID.ts b/src/verifiers/w3c-did/resolveDID.ts new file mode 100644 index 00000000..8e24a83a --- /dev/null +++ b/src/verifiers/w3c-did/resolveDID.ts @@ -0,0 +1,20 @@ +import * as request from "request-promise-native"; +import { DIDDocument } from "../../types/w3c-did"; +import { BASE_RESOLVER_URL, RESOLVER_VERSION, RESOLVER_PATH } from "../../config"; + +/** + * Resolve a DID to it's associated DIDDocument. + * Currently leveraging the public universal resolver to support many did-methods. + * @todo + * - Consider... currently reliant on external service @ `BASE_RESOLVER_URL` staying online / being accessible from wherever + * the verifier is run. Could look to host our own univeral resolver instance as per: + * https://github.com/decentralized-identity/universal-resolver#quick-start + */ +export const resolveDID = async (did: string): Promise => { + const uri = `${BASE_RESOLVER_URL}/${RESOLVER_VERSION}/${RESOLVER_PATH}/${did}`; + const options = { uri, json: true }; + const resolvedDID = await request.get(options); + const { didDocument } = resolvedDID; + if (!didDocument) throw new Error(`DIDDoc could not be retireved for: ${did}`); + return didDocument; +}; diff --git a/test/fixtures/v3/documentRopstenValidWithIssuerDID.ts b/test/fixtures/v3/documentRopstenValidWithIssuerDID.ts new file mode 100644 index 00000000..0a4eb0b1 --- /dev/null +++ b/test/fixtures/v3/documentRopstenValidWithIssuerDID.ts @@ -0,0 +1,39 @@ +import { v3, WrappedDocument } from "@govtechsg/open-attestation"; + +export const documentRopstenValidWithIssuerDID: WrappedDocument = { + version: "open-attestation/3.0", + data: { + reference: "8354acc7-74ab-4cab-be1c-1bf1e10a6920:string:ABCXXXXX00", + name: "1c1df86c-168e-4519-805b-f38698e5b00e:string:Certificate of whatever", + template: { + name: "a63b3426-3aeb-4f99-a88c-18c99940e108:string:CUSTOM_TEMPLATE", + type: "514a184c-c68c-42dd-816a-133d58dad24d:string:EMBEDDED_RENDERER", + url: "cabe4859-a1e6-4394-a88f-4a3bb30e05ae:string:http://localhost:3000/rederer" + }, + validFrom: "b72d0f94-e7a0-47b8-bbb2-91bc7397c406:string:2018-08-30T00:00:00+08:00", + proof: { + type: "0a9e819c-1e18-4c6e-bd2b-0d34e97e3d27:string:OpenAttestationSignature2018", + method: "006b1ad2-c284-4373-9742-b83ab97bf173:string:DOCUMENT_STORE", + value: "434d9cf9-5ce1-4ac0-a960-4badd935834c:string:0x8Fc57204c35fb9317D91285eF52D6b892EC08cD3" + }, + issuer: { + id: "dca00886-d384-4218-bbf5-9699f2a6f274:string:https://example.com", + name: "d8b5c027-69ce-4c6d-93e0-ef72da26ae36:string:Issuer name", + identityProof: { + // Update identity proof to use W3C-DID type + type: "1c5ce8f4-7fcc-4285-9d33-5d3c38c53ad1:string:W3C-DID", + location: + "79009458-fdd6-42fe-a3b9-d13ac8d4ca91:string:did:ethr:ropsten:0xB26B4941941C51a4885E5B7D3A1B861E54405f90" + } + } + }, + privacy: { + obfuscatedData: [] + }, + signature: { + type: "SHA3MerkleProof", + targetHash: "7f42e288dfdb20e7f9a62329adf1f3ad8eed0345a2c517ee7af3e9e88d02a5cd", + proof: [], + merkleRoot: "7f42e288dfdb20e7f9a62329adf1f3ad8eed0345a2c517ee7af3e9e88d02a5cd" + } +};