diff --git a/iam/.eslintignore b/iam/.eslintignore index 16765b0609..cf5a1843cf 100644 --- a/iam/.eslintignore +++ b/iam/.eslintignore @@ -3,5 +3,5 @@ /dist/* /coverage/* /node_modules/* -**/__mocks__/**/* -**/__tests__/**/* +/__mocks__/**/* +/__tests__/**/* diff --git a/iam/src/providers/simple.ts b/iam/src/providers/simple.ts index 4431024e41..d41bdd690f 100644 --- a/iam/src/providers/simple.ts +++ b/iam/src/providers/simple.ts @@ -1,6 +1,6 @@ // ----- Types -import { Provider, ProviderOptions } from "../types"; -import { RequestPayload, VerifiedPayload } from "@dpopp/types"; +import type { Provider, ProviderOptions } from "../types"; +import type { RequestPayload, VerifiedPayload } from "@dpopp/types"; // Export a simple Provider as an example export class SimpleProvider implements Provider { diff --git a/iam/src/types.ts b/iam/src/types.d.ts similarity index 100% rename from iam/src/types.ts rename to iam/src/types.d.ts diff --git a/iam/src/utils/providers.ts b/iam/src/utils/providers.ts index c9f64aafda..dfc33c81ee 100644 --- a/iam/src/utils/providers.ts +++ b/iam/src/utils/providers.ts @@ -1,6 +1,6 @@ // ---- Types -import { Provider } from "../types"; -import { RequestPayload, ChallengePayload, VerifiedPayload } from "@dpopp/types"; +import type { Provider } from "../types"; +import type { RequestPayload, ChallengePayload, VerifiedPayload } from "@dpopp/types"; // ---- Return randomBytes as a challenge to test that the user has control of a provided address import crypto from "crypto"; diff --git a/identity/.eslintignore b/identity/.eslintignore index 3fa4be172f..a110658985 100644 --- a/identity/.eslintignore +++ b/identity/.eslintignore @@ -5,5 +5,5 @@ /node_modules/* /src/didkit-node/* /src/didkit-browser/* -**/__mocks__/**/* -**/__tests__/**/* +/__mocks__/**/* +/__tests__/**/* diff --git a/identity/__mocks__/axios.js b/identity/__mocks__/axios.js index b80efa445c..62367b8791 100644 --- a/identity/__mocks__/axios.js +++ b/identity/__mocks__/axios.js @@ -1,32 +1,32 @@ module.exports = { - post: async (url, data) => { + post: jest.fn(async (url, data) => { switch (url) { - case "/vbad/challenge": + case "/vTest-Case-1/challenge": return { data: { credential: { - credentialSubject: {}, + credentialSubject: { + challenge: "this is a challenge", + }, }, }, }; - case "/vbad/verify": + case "/vTest-Case-1/verify": return { data: { credential: {}, record: {}, }, }; - case "/v0.0.0/challenge": + case "/vTest-Case-2/challenge": return { data: { credential: { - credentialSubject: { - challenge: "this is a challenge", - }, + credentialSubject: {}, }, }, }; - case "/v0.0.0/verify": + case "/vTest-Case-2/verify": return { data: { credential: {}, @@ -34,5 +34,5 @@ module.exports = { }, }; } - }, + }), }; diff --git a/identity/__mocks__/didkit.js b/identity/__mocks__/didkit.js new file mode 100644 index 0000000000..32ec6bebd4 --- /dev/null +++ b/identity/__mocks__/didkit.js @@ -0,0 +1,24 @@ +// ---- Generate & Verify methods +module.exports = { + keyToDID: jest.fn(() => Promise.resolve("did:key:PUBLIC_KEY")), + keyToVerificationMethod: jest.fn(() => Promise.resolve("did:key:PUBLIC_KEY#PUBLIC_KEY")), + issueCredential: jest.fn((credential) => + Promise.resolve( + JSON.stringify({ + ...JSON.parse(credential), + ...{ + proof: {}, + }, + }) + ) + ), + verifyCredential: jest.fn(() => + Promise.resolve( + JSON.stringify({ + checks: [], + warnings: [], + errors: [], + }) + ) + ), +}; diff --git a/identity/__tests__/credentials.test.ts b/identity/__tests__/credentials.test.ts index 2564816940..0c8aa82739 100644 --- a/identity/__tests__/credentials.test.ts +++ b/identity/__tests__/credentials.test.ts @@ -3,141 +3,175 @@ import { issueChallengeCredential, issueMerkleCredential, verifyCredential, - fetchChallengeCredential, - fetchVerifiableCredential, + fetchChallengeCredential as FetchChallengeCredential, + fetchVerifiableCredential as FetchVerifiableCredential, } from "../src/credentials"; -// ---Types -import { CredentialResponseBody } from "@dpopp/types"; +// ---- Types +import { Axios } from "axios"; +import { DIDKitLib, VerifiableCredential } from "@dpopp/types"; -// ---- Generate & Verify methods -import * as DIDKit from "@dpopp/identity/dist/commonjs/didkit-node"; -// @TODO - remove example key - this should be supplied by the .env -const key = JSON.stringify({ - kty: "OKP", - crv: "Ed25519", - x: "a7wbszn1DfZ3I7-_zDkUXCgypcGxL_cpCSTYEPRYj_o", - d: "Z0hucmxRt1C22ygAXJ1arXwD9QlAA5tEPLb7qoXYDGY", -}); +// ---- Set up test state (we reset these between runs to encapsulate the jest state) +let axios: Axios; +let DIDKit: DIDKitLib; +let fetchChallengeCredential: typeof FetchChallengeCredential; +let fetchVerifiableCredential: typeof FetchVerifiableCredential; -// get DID from key -const issuer = DIDKit.keyToDID("key", key); +// this would need to be a valid key but we've mocked out didkit (and no verifications are made) +const key = "SAMPLE_KEY"; describe("Fetch Credentials", function () { + beforeEach(() => { + return import("axios").then((module) => { + // reset axios + axios = module as unknown as Axios; + + return import("../src/credentials").then((module) => { + // reset fetchChallengeCredential & fetchVerifiableCredential + fetchChallengeCredential = module.fetchChallengeCredential; + fetchVerifiableCredential = module.fetchVerifiableCredential; + + // reset jest counters + jest.resetModules(); + }); + }); + }); + it("can fetch a challenge credential", async () => { - // fetchChallengeCredential will rewrap the response of the axios post request - const { challenge } = await fetchChallengeCredential("", { + // the returned values are fetched from the mocked axios.post(...) + await fetchChallengeCredential("", { address: "0x0", type: "Simple", - version: "0.0.0", + version: "Test-Case-1", }); - // check that we got back the mocked challenge response - expect(JSON.stringify(challenge)).toEqual('{"credentialSubject":{"challenge":"this is a challenge"}}'); + // check that called the axios.post fn + expect(axios.post).toHaveBeenCalled(); }); + it("can fetch a verifiable credential", async () => { - const { credential, record, signature, challenge } = await fetchVerifiableCredential( + // mock the message signer + const signMessage = jest.fn().mockImplementation(() => Promise.resolve("Signed Message")); + + // the returned values are fetched from the mocked axios.post(...) + await fetchVerifiableCredential( "", { address: "0x0", type: "Simple", - version: "0.0.0", + version: "Test-Case-1", }, { - signMessage: async (message) => { - return "Signed Message"; - }, + signMessage, } ); - expect(signature).toEqual("Signed Message"); - expect(JSON.stringify(challenge)).toEqual('{"credentialSubject":{"challenge":"this is a challenge"}}'); - expect(JSON.stringify(credential)).toEqual("{}"); - expect(JSON.stringify(record)).toEqual("{}"); + // called to fetch the challenge and to verify + expect(axios.post).toHaveBeenCalledTimes(2); + // we expect to get back the mocked response + expect(signMessage).toHaveBeenCalled(); }); - it("will fail if not provided a signer to sign the message", async () => { - const { credential, record, signature, challenge } = await fetchVerifiableCredential( - "", - { - address: "0x0", - type: "Simple", - version: "bad", - }, - undefined - ); - expect(signature).toEqual(""); - - expect(JSON.stringify(challenge)).toEqual('{"credentialSubject":{}}'); - expect(JSON.stringify(credential)).toEqual("{}"); - expect(JSON.stringify(record)).toEqual("{}"); - }); - it("will not attempt to sign if not provided a challenge in the challenge credential", async () => { - const { credential, record, signature, challenge } = await fetchVerifiableCredential( - "", - { - address: "0x0", - type: "Simple", - version: "bad", - }, - { - signMessage: async (message) => { - return "Signed Message"; + it("will fail if not provided a signer to sign the message", async () => { + // without a signer we are unable to sign the message and the signature will be passed as an empty string + await expect( + fetchVerifiableCredential( + "", + { + address: "0x0", + type: "Simple", + version: "Test-Case-1", }, - } - ); - - expect(signature).toEqual(""); - - expect(JSON.stringify(challenge)).toEqual('{"credentialSubject":{}}'); - expect(JSON.stringify(credential)).toEqual("{}"); - expect(JSON.stringify(record)).toEqual("{}"); + undefined + ) + ).rejects.toThrow("Unable to sign message"); }); + it("will throw if signer rejects request for signature", async () => { + // if the user rejects the signing then the signer will throw an error... await expect( fetchVerifiableCredential( "", { address: "0x0", type: "Simple", - version: "0.0.0", + version: "Test-Case-1", }, { - signMessage: async (message) => { + signMessage: async () => { throw new Error("Unable to sign"); }, } ) ).rejects.toThrow("Unable to sign"); }); + + it("will not attempt to sign if not provided a challenge in the challenge credential", async () => { + // mock the message signer + const signMessage = jest.fn().mockImplementation(() => Promise.resolve("Signed Message")); + + // NOTE: The mocked axios "/vTest-Case-2/challenge" endpoint doesn't return a challenge + await expect( + fetchVerifiableCredential( + "", + { + address: "0x0", + type: "Simple", + version: "Test-Case-2", + }, + { + signMessage, + } + ) + ).rejects.toThrow("Unable to sign message"); + + // NOTE: the signMessage function was never called + expect(signMessage).not.toBeCalled(); + }); }); describe("Generate Credentials", function () { + beforeEach(() => { + return import("../__mocks__/didkit.js").then((module) => { + DIDKit = module as unknown as DIDKitLib; + jest.resetModules(); + }); + }); + it("can generate a challenge credential", async () => { const record = { type: "Simple", address: "0x0", - version: "0.0.0", + version: "Test-Case-1", challenge: "randomChallengeString", }; + // details of this credential are created by issueChallengeCredential - but the proof is added by DIDKit (which is mocked) const { credential } = await issueChallengeCredential(DIDKit, key, record); + // expect to have called issueCredential + expect(DIDKit.issueCredential).toHaveBeenCalled(); + // expect the structure/details added by issueMerkleCredential to be correct expect(credential.credentialSubject.id).toEqual(`did:ethr:${record.address}#challenge-${record.type}`); expect(credential.credentialSubject.challenge).toEqual(record.challenge); expect(credential.credentialSubject.address).toEqual(record.address); expect(typeof credential.proof).toEqual("object"); }); + it("can generate a merkle credential", async () => { const record = { type: "Simple", address: "0x0", - version: "0.0.0", + version: "Test-Case-1", }; + // details of this credential are created by issueMerkleCredential - but the proof is added by DIDKit (which is mocked) const { credential } = await issueMerkleCredential(DIDKit, key, record); + // expect to have called issueCredential + expect(DIDKit.issueCredential).toHaveBeenCalled(); + // expect the structure/details added by issueMerkleCredential to be correct expect(credential.credentialSubject.id).toEqual(`did:ethr:${record.address}#${record.type}`); expect(typeof credential.credentialSubject.root === "string").toEqual(true); expect(typeof credential.proof).toEqual("object"); @@ -145,72 +179,42 @@ describe("Generate Credentials", function () { }); describe("Verify Credentials", function () { + beforeEach(() => { + return import("../__mocks__/didkit.js").then((module) => { + DIDKit = module as unknown as DIDKitLib; + jest.resetModules(); + }); + }); + it("can verify a credential", async () => { const record = { type: "Simple", address: "0x0", - version: "0.0.0", + version: "Test-Case-1", }; + // we are creating this VC so that we know that we have a valid VC in this context to test against (never expired) const { credential } = await issueMerkleCredential(DIDKit, key, record); + // all verifications will pass as the DIDKit response is mocked expect(await verifyCredential(DIDKit, credential)).toEqual(true); - }); - - it("cannot verify a credential that has been modified", async () => { - const credential = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential"], - credentialSubject: { - id: "did:ethr:THIS_IS_A_DUMMY_ADDRESS_TO_TEST_ALTERED_SIG_VERIFY#Simple", - "@context": [ - { - root: "https://schema.org/Text", - }, - ], - root: "4tPCpmsNW5ndVJCYW9akgvXcFqVcRW7OrZH4oPBe2gE=", - }, - issuer: "did:key:z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw", - issuanceDate: "2022-04-07T15:07:17.392Z", - proof: { - type: "Ed25519Signature2018", - proofPurpose: "assertionMethod", - verificationMethod: - "did:key:z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw#z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw", - created: "2022-04-07T15:07:17.392Z", - jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..hG5PhIsYIbzEcSWtVWxVfodzWLIgY5ZykqZwB1xbsYBSSvcgxyYUJwhf7DhBnQF9tAnDBX7F9LAciwnc__WVBg", - }, - expirationDate: "2022-05-07T15:07:17.392Z", - }; - - expect(await verifyCredential(DIDKit, credential)).toEqual(false); + // expect to have called verifyCredential + expect(DIDKit.verifyCredential).toHaveBeenCalled(); }); it("cannot verify a valid but expired credential", async () => { + // create a date and move it into the past + const expired = new Date(); + expired.setSeconds(expired.getSeconds() - 1); + + // if the expiration date is in the past then this VC has expired const credential = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential"], - credentialSubject: { - id: "did:ethr:0x0#Simple", - "@context": [ - { - root: "https://schema.org/Text", - }, - ], - root: "4tPCpmsNW5ndVJCYW9akgvXcFqVcRW7OrZH4oPBe2gE=", - }, - issuer: "did:key:z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw", - issuanceDate: "2022-03-08T16:23:39.650Z", - proof: { - type: "Ed25519Signature2018", - proofPurpose: "assertionMethod", - verificationMethod: - "did:key:z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw#z6Mkmhp2sE9s4AxFrKUXQjcNxbDV7WTM8xdh1FDNmNDtogdw", - created: "2022-04-07T15:23:39.650Z", - jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..MrSSojnjzmH_W3YWaPxt514egv994UMNUswL7Wq5s6ogx3A5Jfs7JjuucJEeMFobJ7Iuc3y28olYj-M5REVTCA", - }, - expirationDate: "2022-03-09T16:23:39.650Z", - }; + expirationDate: expired.toISOString(), + } as unknown as VerifiableCredential; + + // before the credential is verified against DIDKit - we check its expiration date... expect(await verifyCredential(DIDKit, credential)).toEqual(false); + // expect to have not called verify on didkit + expect(DIDKit.verifyCredential).not.toBeCalled(); }); }); diff --git a/identity/__tests__/merkle.test.ts b/identity/__tests__/merkle.test.ts index 221916e012..abd45a0e8e 100644 --- a/identity/__tests__/merkle.test.ts +++ b/identity/__tests__/merkle.test.ts @@ -1,4 +1,4 @@ -// ---- Test subject +// ---- Test subject - (NOTE: this is an integration test rather than a unit test) import { ProofRecord } from "@dpopp/types"; import { generateMerkle, verifyMerkleProof } from "../src/merkle"; @@ -7,7 +7,6 @@ const record = { type: "Simple", address: "0x0", version: "0.0.0", - username: "test", }; // Generate the merkleTree @@ -15,15 +14,21 @@ const merkle = generateMerkle(record); describe("MerkleTree", function () { it("can generate a merkleTree", () => { - expect(merkle.root).toEqual("gv8oALCsnbsEMM9gSzJYN7d49UJ/CvPN3t9Xenj70gM="); + expect(merkle.root).toEqual("4tPCpmsNW5ndVJCYW9akgvXcFqVcRW7OrZH4oPBe2gE="); + }); + it("cannot generate a merkleTree if we're not providing any leafs", () => { + // we need atleast one leaf to get a root and two to generate proofs + expect(() => generateMerkle({} as ProofRecord)).toThrow( + "Add more leafs before attempting to construct a merkleTree" + ); }); it("cannot generate a merkleTree if we're not providing enough leafs", () => { - // Generate the merkleTree - const failMerkle = generateMerkle({ - type: "Simple", - } as ProofRecord); - // we need atleast two leafs to construct the tree - expect(failMerkle.proofs.type.length).toEqual(0); + // we need atleast one leaf to get a root and two to generate proofs + expect(() => + generateMerkle({ + type: "Simple", + } as ProofRecord) + ).toThrow("Add more leafs before attempting to construct a merkleTree"); }); it("can verify a merkle proof", () => { const verifyMerkle = verifyMerkleProof(merkle.proofs.address, record.address, merkle.root); @@ -37,10 +42,4 @@ describe("MerkleTree", function () { const verifyMerkle = verifyMerkleProof(null, record.address, merkle.root); expect(verifyMerkle).toEqual(false); }); - it("cannot generate a merkleTree if we're not providing any leafs", () => { - // Generate the merkleTree - const failMerkle = generateMerkle({} as ProofRecord); - // we need atleast two leafs to construct the tree - expect(JSON.stringify(failMerkle.proofs)).toEqual("{}"); - }); }); diff --git a/identity/src/credentials.ts b/identity/src/credentials.ts index d795f66052..e92b97fe58 100644 --- a/identity/src/credentials.ts +++ b/identity/src/credentials.ts @@ -24,7 +24,7 @@ const addSeconds = (date: Date, seconds: number): Date => { return result; }; -// internal method to issue a verfiable credential +// Internal method to issue a verfiable credential const _issueCredential = async ( DIDKit: DIDKitLib, key: string, @@ -167,6 +167,11 @@ export const fetchVerifiableCredential = async ( ? (await signer.signMessage(challenge.credentialSubject.challenge)).toString() : ""; + // must provide signature for message + if (!signature) { + throw new Error("Unable to sign message"); + } + // pass the signature as part of the proofs obj payload.proofs = { ...payload.proofs, ...{ signature: signature } }; diff --git a/identity/src/merkle.ts b/identity/src/merkle.ts index a8dc70e231..7aeb72fd02 100644 --- a/identity/src/merkle.ts +++ b/identity/src/merkle.ts @@ -9,43 +9,56 @@ export const generateMerkle = ( record: ProofRecord ): { proofs: { [key: string]: Proof[] | null }; - root: string; + root: string | undefined; } => { - // props to access the merkle leafs - let counter = 0; - let prop: keyof typeof record; - // generate a new tree const merkleTools = new MerkleTools({ hashType: "sha256", }); // each proof relates to an entry in Payload.record - const proofs: { [key: string]: Proof[] | null } = {}; + const proofs: { [key: string]: Proof[] } = {}; + // associate the key with its counter index in merkle + let counter = 0; + // props to access the merkle leafs + let prop: keyof typeof record; + // after reading back the proofs ensure they hold a value before returning the root+proofs response + let validProofs = true; + + // add each record as a leaf on the merkleTree for (prop in record) { if (Object.hasOwnProperty.call(record, prop)) { - // add leaf to merkle merkleTools.addLeaf(record[prop], true); } } - if (Object.keys(record).length > 0) { - // make the tree + // make the tree - (if the tree isnt constructed then we get back a null root + proof) + if (Object.keys(record).length > 1) { merkleTools.makeTree(); } - // get proof for each item of record + // once the tree has been created get the proof for each item of record for (prop in record) { if (Object.hasOwnProperty.call(record, prop)) { // set proof and incr counter - proofs[prop] = merkleTools.getProof(counter++); + proofs[prop] = merkleTools.getProof(counter++) || []; + // check that the proof exists and holds values + validProofs = proofs[prop].length === 0 ? false : validProofs; } } + // extract the root + const root = merkleTools.getMerkleRoot(); + + // if there are not enough items to record in to the tree... + if (root === null || !validProofs) { + throw new Error("Add more leafs before attempting to construct a merkleTree"); + } + // return content required to carry out verification of the merkleTree content return { proofs, - root: merkleTools.getMerkleRoot()?.toString("base64") || "", + root: root.toString("base64"), }; };